From 4405a83d118b669d80d2fd364a2b01333a2505e7 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:10:31 -0700 Subject: [PATCH 1/2] Fix hang wrapping OSC-8 hyperlinks with wide graphemes. When wrap width is 1 and an open hyperlink precedes a wide/emoji grapheme, the wrap loop never advanced. Advance past the grapheme. --- tests/test_textwrap.py | 10 ++++++++++ wcwidth/textwrap.py | 24 ++++++++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/test_textwrap.py b/tests/test_textwrap.py index 33da72a4..e368dc01 100644 --- a/tests/test_textwrap.py +++ b/tests/test_textwrap.py @@ -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.""" diff --git a/wcwidth/textwrap.py b/wcwidth/textwrap.py index 0cf2ced4..0b2b4951 100644 --- a/wcwidth/textwrap.py +++ b/wcwidth/textwrap.py @@ -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:] @@ -504,6 +504,18 @@ 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.""" segments = list(iter_sequences(text)) From 37b4b2b67d987476061337d740b093d87b118c36 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:51:12 -0700 Subject: [PATCH 2/2] refactor: drop unused first-grapheme helper _find_first_visible_break fully supersedes the old force-progress path. --- wcwidth/textwrap.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/wcwidth/textwrap.py b/wcwidth/textwrap.py index 0b2b4951..28b7fcff 100644 --- a/wcwidth/textwrap.py +++ b/wcwidth/textwrap.py @@ -500,10 +500,6 @@ 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