Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions tests/test_textwrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,16 @@ def test_wrap_tabsize_wide_chars(text, w, tabsize, expected):
'\x1b]8;foo=bar:id=mylink;http://example.com\x1b\\Click\x1b]8;;\x1b\\',
'\x1b]8;foo=bar:id=mylink;http://example.com\x1b\\here\x1b]8;;\x1b\\',
],
),
( # wide grapheme after OSC 8 open at width 1 (must not hang)
'\x1b]8;;u\x07😀',
1,
['\x1b]8;id=00000001;u\x07😀\x1b]8;;\x07'],
),
( # CJK wide char after OSC 8 open at width 1 (must not hang)
'\x1b]8;;u\x07あ',
1,
['\x1b]8;id=00000001;u\x07あ\x1b]8;;\x07'],
),])
def test_wrap_hyperlink_word_boundary(text, w, expected):
"""OSC hyperlink sequences should act as word boundaries."""
Expand Down
26 changes: 17 additions & 9 deletions wcwidth/textwrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,12 +440,12 @@ def _handle_long_word(self, reversed_chunks: list[str],
actual_end = hyphen_end
else:
actual_end = self._find_break_position(chunk, space_left)
# If no progress possible (e.g., wide char exceeds line width),
# force at least one grapheme to avoid infinite loop.
# Only force when cur_line is empty; if line has content,
# appending nothing is safe and the line will be committed.
if actual_end == 0 and not cur_line:
actual_end = self._find_first_grapheme_end(chunk)
# Include first visible unit when break would take only leading sequences.
if not cur_line and (
actual_end == 0
or (actual_end < len(chunk)
and self._width(chunk[:actual_end]) == 0)):
actual_end = self._find_first_visible_break(chunk)
cur_line.append(chunk[:actual_end])
reversed_chunks[-1] = chunk[actual_end:]

Expand Down Expand Up @@ -500,9 +500,17 @@ def _find_break_position(self, text: str, max_width: int) -> int:
# exceeds and we return from within the loop. Type checker requires this.
return idx # pragma: no cover

def _find_first_grapheme_end(self, text: str) -> int:
"""Find the end position of the first grapheme."""
return len(next(iter_graphemes(text)))
def _find_first_visible_break(self, text: str) -> int:
"""End of leading escape sequences plus the first grapheme."""
idx = 0
while idx < len(text) and text[idx] == '\x1b':
match = ZERO_WIDTH_PATTERN.match(text, idx)
if match is None:
break
idx = match.end()
if idx >= len(text):
return len(text)
return idx + len(next(iter_graphemes(text, start=idx)))

def _rstrip_visible(self, text: str) -> str:
"""Strip trailing visible whitespace, preserving trailing sequences."""
Expand Down
Loading