refactor(tui): extract _fold_chunk_cells helper for CJK wrap

The two CJK-aware drop-ins (divide_line for Rich/textual.content, and
compute_wrap_offsets for textual._wrap/_wrapped_document) shared
identical greedy-pack-then-fold core logic, written twice. Extract into
_fold_chunk_cells so each call site only handles interface shape (Rich
words vs Textual chunk regex, tab handling).

Also extend the patch to cover textual._wrap.compute_wrap_offsets and
textual.document._wrapped_document — the TextArea / Static-via-Content
paths use compute_wrap_offsets, not divide_line, so the prior patch
missed input-box and user-bubble wrap. Drop try/except defensiveness
around the Textual patch (we target latest Textual; let real breakage
surface), split _CJK_WRAP_RE into commented sub-ranges, prune WHAT
comments.

Verified: non-CJK output remains byte-equivalent to upstream across
both divide_line and compute_wrap_offsets fixture sets; live render at
width 60 packs the first line to ~98% utilization on input box, USER
bubble, and AGENT bubble.
This commit is contained in:
yanghang committed 2026-05-17 01:52:37 +08:00
1 parent a74b270a94
commit e2c4649a85
1 file changed
+124 -57
+124 -57
View File
@@ -280,89 +280,156 @@ class HardBreakMarkdown(Markdown):
HardBreakMarkdown._soft_to_hard(tok.children)
# Rich's default divide_line treats a CJK run as one indivisible word and bumps
# it whole to the next line when it doesn't fit the remaining space, wasting
# the tail of the current line and producing wraps like "AI ↩ 助手...". Patch
# both module bindings so Text.wrap (used by Markdown) sees CJK as breakable.
_CJK_WRAP_RE = re.compile(r"[ -鿿가-힯＀-￯]")
# Rich/Textual wrap treats a continuous CJK run as one indivisible word and
# bumps it whole to the next line when it doesn't fit the remaining space,
# leaving the line tail padded and producing wraps like "AI ↩ 助手...". We patch
# every binding of divide_line/compute_wrap_offsets so CJK-bearing chunks pack
# leading chars into the remainder then fold the rest at full width.
# Covers CJK Unified Ideographs, Hangul Syllables, fullwidth/halfwidth forms.
_CJK_WRAP_RE = re.compile(
r"[ -鿿" # CJK punctuation through Unified Ideographs
r"가-힯" # Hangul Syllables
r"＀-￯]" # Halfwidth / Fullwidth Forms
)
def _fold_chunk_cells(chunk, width, char_width_fn, line_offset=0):
"""Walk chunk char-by-char; return (breaks_relative_to_chunk, final_offset).
A break at index i means a newline lands before chunk[i]. line_offset is the
column where chunk[0] starts. char_width_fn must be called in order — it may
carry state (e.g. tab section index).
"""
breaks: list[int] = []
for i, ch in enumerate(chunk):
cw = char_width_fn(ch)
if line_offset > 0 and line_offset + cw > width:
breaks.append(i)
line_offset = cw
else:
line_offset += cw
return breaks, line_offset
def _cjk_divide_line(text: str, width: int, fold: bool = True) -> list[int]:
from rich._wrap import words as _rich_words
from rich.cells import cell_len as _clen, chop_cells as _chop
from rich._wrap import words as _words
from rich.cells import cell_len as _clen
break_positions: list[int] = []
breaks: list[int] = []
cell_offset = 0
for start, _end, word in _rich_words(text):
for start, _end, word in _words(text):
word_length = _clen(word.rstrip())
remaining = width - cell_offset
if remaining >= word_length:
if width - cell_offset >= word_length:
cell_offset += _clen(word)
continue
if not fold:
if cell_offset:
break_positions.append(start)
breaks.append(start)
cell_offset = _clen(word)
continue
if _CJK_WRAP_RE.search(word):
# Pack as many leading chars as fit in remaining space, then fold rest.
prefix_chars = 0
if cell_offset > 0 and remaining > 0:
accum = 0
for i, ch in enumerate(word):
cw = _clen(ch)
if accum + cw > remaining:
break
accum += cw
prefix_chars = i + 1
if prefix_chars > 0:
break_positions.append(start + prefix_chars)
rest = word[prefix_chars:]
cursor = start + prefix_chars
else:
if cell_offset:
break_positions.append(start)
rest = word
cursor = start
if _clen(rest) > width:
chunks = _chop(rest, width)
for i in range(len(chunks) - 1):
cursor += len(chunks[i])
break_positions.append(cursor)
cell_offset = _clen(chunks[-1].rstrip())
else:
cell_offset = _clen(rest.rstrip())
has_cjk = bool(_CJK_WRAP_RE.search(word))
if not has_cjk and word_length <= width:
if cell_offset:
breaks.append(start)
cell_offset = _clen(word)
continue
# Non-CJK: keep stock behavior.
if word_length > width:
folded_word = _chop(word, width=width)
cursor = start
for i, line in enumerate(folded_word):
if cursor != 0 or i > 0:
break_positions.append(cursor)
if i < len(folded_word) - 1:
cursor += len(line)
cell_offset = _clen(folded_word[-1])
elif cell_offset and start:
break_positions.append(start)
cell_offset = _clen(word)
return break_positions
if has_cjk:
line_offset = cell_offset
else:
if cell_offset:
breaks.append(start)
line_offset = 0
sub_breaks, cell_offset = _fold_chunk_cells(
word, width, _clen, line_offset
)
breaks.extend(start + b for b in sub_breaks)
return breaks
def _cjk_compute_wrap_offsets(text, width, tab_size, fold=True,
precomputed_tab_sections=None):
from rich.cells import get_character_cell_size
from textual._cells import cell_len as _clen
from textual._loop import loop_last
from textual.expand_tabs import get_tab_widths
tab_size = min(tab_size, width)
tab_sections = precomputed_tab_sections or get_tab_widths(text, tab_size)
cumulative_widths: list[int] = []
cumulative_width = 0
for last, (tab_section, tab_width) in loop_last(tab_sections):
cumulative_widths.extend([cumulative_width] * (len(tab_section) + int(bool(tab_width))))
cumulative_width += tab_width
if last:
cumulative_widths.append(cumulative_width)
tab_idx = [0]
def char_width(ch):
if ch == "\t":
cw = tab_sections[tab_idx[0]][1]
tab_idx[0] += 1
return cw
return get_character_cell_size(ch)
breaks: list[int] = []
cell_offset = 0
pos = 0
chunk_re = re.compile(r"\S+\s*|\s+")
while pos < len(text):
m = chunk_re.match(text, pos)
if m is None:
break
start, end = m.span()
chunk = m.group(0)
pos = end
chunk_width = _clen(chunk) + (cumulative_widths[end] - cumulative_widths[start])
if width - cell_offset >= chunk_width:
cell_offset += chunk_width
continue
if not fold:
if cell_offset:
breaks.append(start)
cell_offset = chunk_width
continue
has_cjk = bool(_CJK_WRAP_RE.search(chunk))
if not has_cjk and chunk_width <= width:
if cell_offset:
breaks.append(start)
cell_offset = chunk_width
continue
if has_cjk:
line_offset = cell_offset
else:
if cell_offset:
breaks.append(start)
line_offset = 0
sub_breaks, cell_offset = _fold_chunk_cells(chunk, width, char_width, line_offset)
breaks.extend(start + b for b in sub_breaks)
return breaks
def _install_cjk_wrap() -> None:
# `from rich._wrap import divide_line` in textual.content / rich.text creates
# a binding copy that survives our rebind on rich._wrap — patch each holder.
# `from X import fn` copies the binding into the importer's namespace, so a
# rebind on the source module misses every holder. Patch each one explicitly.
import rich._wrap as _rw
import rich.text as _rt
import textual.content as _tc
if getattr(_rw.divide_line, "_cjk_patched", False):
import textual._wrap as _tw
import textual.document._wrapped_document as _twd
if getattr(_cjk_divide_line, "_cjk_patched", False):
return
_cjk_divide_line._cjk_patched = True
_rw.divide_line = _cjk_divide_line
_rt.divide_line = _cjk_divide_line
_tc.divide_line = _cjk_divide_line
_tw.compute_wrap_offsets = _cjk_compute_wrap_offsets
_twd.compute_wrap_offsets = _cjk_compute_wrap_offsets
_install_cjk_wrap()