codekingpro/portable-devtools
115k
1"""2Sequence-aware text wrapping functions.3 4This module provides functions for wrapping text that may contain terminal escape sequences, with5proper handling of Unicode grapheme clusters and character display widths.6"""7 8from __future__ import annotations9 10# std imports11import secrets12import textwrap13 14from typing import TYPE_CHECKING, Optional15 16# local17from ._width import width as wcwidth_width18from .grapheme import iter_graphemes19from .hyperlink import HyperlinkParams20from .sgr_state import propagate_sgr as _propagate_sgr21from .escape_sequences import ZERO_WIDTH_PATTERN, iter_sequences22 23if TYPE_CHECKING: # pragma: no cover24 from typing import Any, Literal25 26 27class SequenceTextWrapper(textwrap.TextWrapper):28 """29 Sequence-aware text wrapper extending :class:`textwrap.TextWrapper`.30 31 This wrapper properly handles terminal escape sequences and Unicode grapheme clusters when32 calculating text width for wrapping.33 34 This implementation is based on the SequenceTextWrapper from the 'blessed' library, with35 contributions from Avram Lubkin and grayjk.36 37 The key difference from the blessed implementation is the addition of grapheme cluster support38 via :func:`~.iter_graphemes`, providing width calculation for ZWJ emoji sequences, VS-16 emojis39 and variations, regional indicator flags, and combining characters.40 41 OSC 8 hyperlinks are handled specially: when a hyperlink must span multiple lines, each line42 receives complete open/close sequences with a shared ``id`` parameter, ensuring terminals43 treat the fragments as a single hyperlink for hover underlining. If the original hyperlink44 already has an ``id`` parameter, it is preserved; otherwise, one is generated.45 """46 47 def __init__(self, width: int = 70, *,48 control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',49 tabsize: int = 8,50 ambiguous_width: int = 1,51 **kwargs: Any) -> None:52 """53 Initialize the wrapper.54 55 :param width: Maximum line width in display cells.56 :param control_codes: How to handle control sequences (see :func:`~.width`).57 :param tabsize: Tab stop width for tab expansion.58 :param ambiguous_width: Width to use for East Asian Ambiguous (A) characters.59 :param kwargs: Additional arguments passed to :class:`textwrap.TextWrapper`.60 """61 super().__init__(width=width, **kwargs)62 self.control_codes = control_codes63 self.tabsize = tabsize64 self.ambiguous_width = ambiguous_width65 66 @staticmethod67 def _next_hyperlink_id() -> str:68 """Generate unique hyperlink id as 8-character hex string."""69 return secrets.token_hex(4)70 71 def _width(self, text: str) -> int:72 """Measure text width accounting for sequences."""73 return wcwidth_width(text, control_codes=self.control_codes, tabsize=self.tabsize,74 ambiguous_width=self.ambiguous_width)75 76 def _strip_sequences(self, text: str) -> str:77 """Strip all terminal sequences from text."""78 result = []79 for segment, is_seq in iter_sequences(text):80 if not is_seq:81 result.append(segment)82 return ''.join(result)83 84 def _extract_sequences(self, text: str) -> str:85 """Extract only terminal sequences from text."""86 result = []87 for segment, is_seq in iter_sequences(text):88 if is_seq:89 result.append(segment)90 return ''.join(result)91 92 def _split(self, text: str) -> list[str]: # pylint: disable=too-many-locals93 r"""94 Sequence-aware variant of :meth:`textwrap.TextWrapper._split`.95 96 This method ensures that terminal escape sequences don't interfere with the text splitting97 logic, particularly for hyphen-based word breaking. It builds a position mapping from98 stripped text to original text, calls the parent's _split on stripped text, then maps chunks99 back.100 101 OSC hyperlink sequences are treated as word boundaries::102 103 >>> wrap('foo \x1b]8;;https://example.com\x07link\x1b]8;;\x07 bar', 6)104 ['foo', '\x1b]8;;https://example.com\x07link\x1b]8;;\x07', 'bar']105 106 Both BEL (``\x07``) and ST (``\x1b\\``) terminators are supported.107 """108 # pylint: disable=too-many-locals,too-many-branches109 # Build a mapping from stripped text positions to original text positions.110 #111 # Track where each character ENDS so that sequences between characters112 # attach to the following text (not preceding text). This ensures sequences113 # aren't lost when whitespace is dropped.114 #115 # char_end[i] = position in original text right after the i-th stripped char116 char_end: list[int] = []117 stripped_text = ''118 original_pos = 0119 prev_was_hyperlink_close = False120 121 for segment, is_seq in iter_sequences(text):122 if not is_seq:123 # Conditionally insert space after hyperlink close to force word boundary124 if prev_was_hyperlink_close and segment and not segment[0].isspace():125 stripped_text += ' '126 char_end.append(original_pos)127 for char in segment:128 original_pos += 1129 char_end.append(original_pos)130 stripped_text += char131 prev_was_hyperlink_close = False132 else:133 is_hyperlink_close = segment.startswith(('\x1b]8;;\x1b\\', '\x1b]8;;\x07'))134 135 # Conditionally insert space before OSC sequences to artificially create word136 # boundary, but *not* before hyperlink close sequences, to ensure hyperlink is137 # terminated on the same line.138 if (segment.startswith('\x1b]') and stripped_text and not139 stripped_text[-1].isspace()):140 if not is_hyperlink_close:141 stripped_text += ' '142 char_end.append(original_pos)143 144 # Escape sequences advance position but don't add to stripped text145 original_pos += len(segment)146 prev_was_hyperlink_close = is_hyperlink_close147 148 # Add sentinel for final position149 char_end.append(original_pos)150 151 # Use parent's _split on the stripped text152 # pylint: disable-next=protected-access153 stripped_chunks = textwrap.TextWrapper._split(self, stripped_text)154 155 # Handle text that contains only sequences (no visible characters).156 # Return the sequences as a single chunk to preserve them.157 if not stripped_chunks and text:158 return [text]159 160 # Map the chunks back to the original text with sequences161 result: list[str] = []162 stripped_pos = 0163 num_chunks = len(stripped_chunks)164 165 for idx, chunk in enumerate(stripped_chunks):166 chunk_len = len(chunk)167 168 # Start is where previous character ended (or 0 for first chunk)169 start_orig = 0 if stripped_pos == 0 else char_end[stripped_pos - 1]170 171 # End is where next character starts. For last chunk, use sentinel172 # to include any trailing sequences.173 if idx == num_chunks - 1:174 end_orig = char_end[-1] # sentinel includes trailing sequences175 else:176 end_orig = char_end[stripped_pos + chunk_len - 1]177 178 # Extract the corresponding portion from the original text179 # Skip empty chunks (from virtual spaces inserted at OSC boundaries)180 if start_orig != end_orig:181 result.append(text[start_orig:end_orig])182 stripped_pos += chunk_len183 184 return result185 186 def _wrap_chunks(self, chunks: list[str]) -> list[str]: # pylint: disable=too-many-branches187 """188 Wrap chunks into lines using sequence-aware width.189 190 Override TextWrapper._wrap_chunks to use _width instead of len. Follows stdlib's algorithm:191 greedily fill lines, handle long words. Also handle OSC hyperlink processing. When192 hyperlinks span multiple lines, each line gets complete open/close sequences with matching193 id parameters for hover underlining continuity per OSC 8 spec.194 """195 # pylint: disable=too-many-branches,too-many-statements,too-complex,too-many-locals196 # pylint: disable=too-many-nested-blocks197 # the hyperlink code in particular really pushes the complexity rating of this method.198 # preferring to keep it "all in one method" because of so much local state and manipulation.199 if not chunks:200 return []201 202 if self.max_lines is not None:203 if self.max_lines > 1:204 indent = self.subsequent_indent205 else:206 indent = self.initial_indent207 if (self._width(indent)208 + self._width(self.placeholder.lstrip())209 > self.width):210 raise ValueError("placeholder too large for max width")211 212 lines: list[str] = []213 is_first_line = True214 215 hyperlink_state: Optional[HyperlinkParams] = None216 # Track the id we're using for the current hyperlink continuation217 current_hyperlink_id: Optional[str] = None218 219 # Arrange in reverse order so items can be efficiently popped220 chunks = list(reversed(chunks))221 222 while chunks:223 current_line: list[str] = []224 current_width = 0225 226 # Get the indent and available width for current line227 indent = self.initial_indent if is_first_line else self.subsequent_indent228 line_width = self.width - self._width(indent)229 230 # If continuing a hyperlink from previous line, prepend open sequence231 if hyperlink_state is not None:232 open_seq = HyperlinkParams(233 url=hyperlink_state.url,234 params=hyperlink_state.params,235 terminator=hyperlink_state.terminator,236 ).make_open()237 chunks[-1] = open_seq + chunks[-1]238 239 # Drop leading whitespace (except at very start)240 # When dropping, transfer any sequences to the next chunk.241 # Only drop if there's actual whitespace text, not if it's only sequences.242 stripped = self._strip_sequences(chunks[-1])243 if self.drop_whitespace and lines and stripped and not stripped.strip():244 sequences = self._extract_sequences(chunks[-1])245 del chunks[-1]246 if sequences and chunks:247 chunks[-1] = sequences + chunks[-1]248 249 # Greedily add chunks that fit250 while chunks:251 chunk = chunks[-1]252 chunk_width = self._width(chunk)253 254 if current_width + chunk_width <= line_width:255 current_line.append(chunks.pop())256 current_width += chunk_width257 else:258 break259 260 # Handle chunk that's too long for any line261 if chunks and self._width(chunks[-1]) > line_width:262 self._handle_long_word(263 chunks, current_line, current_width, line_width264 )265 current_width = self._width(''.join(current_line))266 # Remove any empty chunks left by _handle_long_word267 while chunks and not chunks[-1]:268 del chunks[-1]269 270 # Drop trailing whitespace271 # When dropping, transfer any sequences to the previous chunk.272 # Only drop if there's actual whitespace text, not if it's only sequences.273 stripped_last = self._strip_sequences(current_line[-1]) if current_line else ''274 if (self.drop_whitespace and current_line and275 stripped_last and not stripped_last.strip()):276 sequences = self._extract_sequences(current_line[-1])277 current_width -= self._width(current_line[-1])278 del current_line[-1]279 if sequences and current_line:280 current_line[-1] = current_line[-1] + sequences281 282 if current_line:283 # Check whether this is a normal append or max_lines284 # truncation. Matches stdlib textwrap precedence:285 # normal if max_lines not set, not yet reached, or no286 # remaining visible content that would need truncation.287 no_more_content = (288 not chunks or289 self.drop_whitespace and290 len(chunks) == 1 and291 not self._strip_sequences(chunks[0]).strip()292 )293 if (self.max_lines is None or294 len(lines) + 1 < self.max_lines or295 no_more_content296 and current_width <= line_width):297 line_content = ''.join(current_line)298 299 # Track hyperlink state through this line's content300 new_state = self._track_hyperlink_state(line_content, hyperlink_state)301 302 # If we end inside a hyperlink, append close sequence303 if new_state is not None:304 # Ensure we have an id for continuation305 if current_hyperlink_id is None:306 if 'id=' in new_state.params:307 current_hyperlink_id = new_state.params308 elif new_state.params:309 # Prepend id to existing params. Per OSC 8 spec, params can have310 # multiple key=value pairs separated by ':'.311 current_hyperlink_id = (312 f'id={self._next_hyperlink_id()}:{new_state.params}')313 else:314 current_hyperlink_id = f'id={self._next_hyperlink_id()}'315 line_content += HyperlinkParams(316 terminator=new_state.terminator, url='').make_close()317 318 # Also need to inject the id into the opening319 # sequence if it didn't have one320 if 'id=' not in new_state.params:321 # Find and replace the original open sequence with one that has id322 old_open = HyperlinkParams(323 url=new_state.url,324 params=new_state.params,325 terminator=new_state.terminator,326 ).make_open()327 new_open = HyperlinkParams(328 url=new_state.url,329 params=current_hyperlink_id,330 terminator=new_state.terminator,331 ).make_open()332 line_content = line_content.replace(old_open, new_open, 1)333 334 # Update state for next line, using computed id335 hyperlink_state = HyperlinkParams(336 new_state.url, current_hyperlink_id, new_state.terminator)337 else:338 hyperlink_state = None339 current_hyperlink_id = None # Reset id when hyperlink closes340 341 # Strip trailing whitespace when drop_whitespace is enabled342 # (matches CPython #140627 fix behavior)343 if self.drop_whitespace:344 line_content = line_content.rstrip()345 lines.append(indent + line_content)346 is_first_line = False347 else:348 # max_lines reached with remaining content.349 # pop chunks until placeholder fits, then break.350 placeholder_w = self._width(self.placeholder)351 while current_line:352 last_text = self._strip_sequences(current_line[-1])353 if (last_text.strip()354 and current_width + placeholder_w <= line_width):355 line_content = ''.join(current_line)356 new_state = self._track_hyperlink_state(357 line_content, hyperlink_state)358 if new_state is not None:359 line_content += HyperlinkParams(360 terminator=new_state.terminator, url='').make_close()361 lines.append(indent + line_content + self.placeholder)362 break363 current_width -= self._width(current_line[-1])364 del current_line[-1]365 else:366 if lines:367 prev_line = self._rstrip_visible(lines[-1])368 if (self._width(prev_line) + placeholder_w369 <= self.width):370 lines[-1] = prev_line + self.placeholder371 break372 lines.append(indent + self.placeholder.lstrip())373 break374 375 return lines376 377 def _track_hyperlink_state(378 self, text: str,379 state: Optional[HyperlinkParams]) -> Optional[HyperlinkParams]:380 """381 Track hyperlink state through text.382 383 :param text: Text to scan for hyperlink sequences.384 :param state: Current state or None if outside hyperlink.385 :returns: Updated state after processing text.386 """387 for segment, is_seq in iter_sequences(text):388 if is_seq:389 parsed_link = HyperlinkParams.parse(segment)390 if parsed_link is not None and parsed_link.url: # has URL = open391 state = parsed_link392 elif segment.startswith(('\x1b]8;;\x1b\\', '\x1b]8;;\x07')): # close393 state = None394 return state395 396 def _handle_long_word(self, reversed_chunks: list[str],397 cur_line: list[str], cur_len: int,398 width: int) -> None:399 """400 Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`.401 402 This method ensures that word boundaries are not broken mid-sequence, and respects grapheme403 cluster boundaries when breaking long words.404 """405 if width < 1:406 space_left = 1407 else:408 space_left = width - cur_len409 410 chunk = reversed_chunks[-1]411 412 if self.break_long_words:413 break_at_hyphen = False414 hyphen_end = 0415 416 # Handle break_on_hyphens: find last hyphen within space_left417 if self.break_on_hyphens:418 # Strip sequences to find hyphen in logical text419 stripped = self._strip_sequences(chunk)420 if len(stripped) > space_left:421 # Find last hyphen in the portion that fits422 hyphen_pos = stripped.rfind('-', 0, space_left)423 if hyphen_pos > 0 and any(c != '-' for c in stripped[:hyphen_pos]):424 # Map back to original position including sequences425 hyphen_end = self._map_stripped_pos_to_original(chunk, hyphen_pos + 1)426 break_at_hyphen = True427 428 # Break at grapheme boundaries to avoid splitting multi-codepoint characters429 if break_at_hyphen:430 actual_end = hyphen_end431 else:432 actual_end = self._find_break_position(chunk, space_left)433 # If no progress possible (e.g., wide char exceeds line width),434 # force at least one grapheme to avoid infinite loop.435 # Only force when cur_line is empty; if line has content,436 # appending nothing is safe and the line will be committed.437 if actual_end == 0 and not cur_line:438 actual_end = self._find_first_grapheme_end(chunk)439 cur_line.append(chunk[:actual_end])440 reversed_chunks[-1] = chunk[actual_end:]441 442 elif not cur_line:443 cur_line.append(reversed_chunks.pop())444 445 def _map_stripped_pos_to_original(self, text: str, stripped_pos: int) -> int:446 """Map a position in stripped text back to original text position."""447 stripped_idx = 0448 original_idx = 0449 450 for segment, is_seq in iter_sequences(text):451 if is_seq:452 original_idx += len(segment)453 elif stripped_idx + len(segment) > stripped_pos:454 # Position is within this segment455 return original_idx + (stripped_pos - stripped_idx)456 else:457 stripped_idx += len(segment)458 original_idx += len(segment)459 460 # Caller guarantees stripped_pos < total stripped chars, so we always461 # return from within the loop. This line satisfies the type checker.462 return original_idx # pragma: no cover463 464 def _find_break_position(self, text: str, max_width: int) -> int:465 """Find string index in text that fits within max_width cells."""466 idx = 0467 width_so_far = 0468 469 while idx < len(text):470 char = text[idx]471 472 # Skip escape sequences (they don't add width)473 if char == '\x1b':474 match = ZERO_WIDTH_PATTERN.match(text, idx)475 if match:476 idx = match.end()477 continue478 479 # Get grapheme (use start= to avoid slice allocation)480 grapheme = next(iter_graphemes(text, start=idx))481 482 grapheme_width = self._width(grapheme)483 if width_so_far + grapheme_width > max_width:484 return idx # Found break point485 486 width_so_far += grapheme_width487 idx += len(grapheme)488 489 # Caller guarantees chunk_width > max_width, so a grapheme always490 # exceeds and we return from within the loop. Type checker requires this.491 return idx # pragma: no cover492 493 def _find_first_grapheme_end(self, text: str) -> int:494 """Find the end position of the first grapheme."""495 return len(next(iter_graphemes(text)))496 497 def _rstrip_visible(self, text: str) -> str:498 """Strip trailing visible whitespace, preserving trailing sequences."""499 segments = list(iter_sequences(text))500 last_vis = -1501 for i, (segment, is_seq) in enumerate(segments):502 if not is_seq and segment.rstrip():503 last_vis = i504 if last_vis == -1:505 return ''506 result = []507 for i, (segment, is_seq) in enumerate(segments):508 if i < last_vis:509 result.append(segment)510 elif i == last_vis:511 result.append(segment.rstrip())512 elif is_seq:513 result.append(segment)514 return ''.join(result)515 516 517def wrap(text: str, width: int = 70, *,518 control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',519 tabsize: int = 8,520 expand_tabs: bool = True,521 replace_whitespace: bool = True,522 ambiguous_width: int = 1,523 initial_indent: str = '',524 subsequent_indent: str = '',525 fix_sentence_endings: bool = False,526 break_long_words: bool = True,527 break_on_hyphens: bool = True,528 drop_whitespace: bool = True,529 max_lines: Optional[int] = None,530 placeholder: str = ' [...]',531 propagate_sgr: bool = True) -> list[str]:532 r"""533 Wrap text to fit within given width, returning a list of wrapped lines.534 535 Like :func:`textwrap.wrap`, but measures width in display cells rather than536 characters, correctly handling wide characters, combining marks, and terminal537 escape sequences.538 539 :param text: Text to wrap, may contain terminal sequences.540 :param width: Maximum line width in display cells.541 :param control_codes: How to handle terminal sequences (see :func:`~.width`).542 :param tabsize: Tab stop width for tab expansion.543 :param expand_tabs: If True (default), tab characters are expanded544 to spaces using ``tabsize``.545 :param replace_whitespace: If True (default), each whitespace character546 is replaced with a single space after tab expansion. When False,547 control whitespace like ``\n`` has zero display width (unlike548 :func:`textwrap.wrap` which counts ``len()``), so wrap points549 may differ from stdlib for non-space whitespace characters.550 :param ambiguous_width: Width to use for East Asian Ambiguous (A)551 characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.552 :param initial_indent: String prepended to first line.553 :param subsequent_indent: String prepended to subsequent lines.554 :param fix_sentence_endings: If True, ensure sentences are always555 separated by exactly two spaces.556 :param break_long_words: If True, break words longer than width.557 :param break_on_hyphens: If True, allow breaking at hyphens.558 :param drop_whitespace: If True (default), whitespace at the beginning559 and end of each line (after wrapping but before indenting) is dropped.560 Set to False to preserve whitespace.561 :param max_lines: If set, output contains at most this many lines, with562 ``placeholder`` appended to the last line if the text was truncated.563 :param placeholder: String appended to the last line when text is564 truncated by ``max_lines``. Default is ``' [...]'``.565 :param propagate_sgr: If True (default), SGR (terminal styling) sequences566 are propagated across wrapped lines. Each line ends with a reset567 sequence and the next line begins with the active style restored.568 :returns: List of wrapped lines without trailing newlines.569 570 SGR (terminal styling) sequences are propagated across wrapped lines571 by default. Each line ends with a reset sequence and the next line572 begins with the active style restored::573 574 >>> wrap('\x1b[1;34mHello world\x1b[0m', width=6)575 ['\x1b[1;34mHello\x1b[0m', '\x1b[1;34mworld\x1b[0m']576 577 Set ``propagate_sgr=False`` to disable this behavior.578 579 Like :func:`textwrap.wrap`, newlines in the input text are treated as580 whitespace and collapsed. To preserve paragraph breaks, wrap each581 paragraph separately::582 583 >>> text = 'First line.\nSecond line.'584 >>> wrap(text, 40) # newline collapsed to space585 ['First line. Second line.']586 >>> [line for para in text.split('\n')587 ... for line in (wrap(para, 40) if para else [''])]588 ['First line.', 'Second line.']589 590 .. seealso::591 592 :func:`textwrap.wrap`, :class:`textwrap.TextWrapper`593 Standard library text wrapping (character-based).594 595 :class:`.SequenceTextWrapper`596 Class interface for advanced wrapping options.597 598 .. versionadded:: 0.3.0599 600 .. versionchanged:: 0.5.0601 Added ``propagate_sgr`` parameter (default True).602 603 .. versionchanged:: 0.6.0604 Added ``expand_tabs``, ``replace_whitespace``, ``fix_sentence_endings``,605 ``drop_whitespace``, ``max_lines``, and ``placeholder`` parameters.606 607 Example::608 609 >>> from wcwidth import wrap610 >>> wrap('hello world', 5)611 ['hello', 'world']612 >>> wrap('中文字符', 4) # CJK characters (2 cells each)613 ['中文', '字符']614 """615 # pylint: disable=too-many-arguments,too-many-locals616 wrapper = SequenceTextWrapper(617 width=width,618 control_codes=control_codes,619 tabsize=tabsize,620 expand_tabs=expand_tabs,621 replace_whitespace=replace_whitespace,622 ambiguous_width=ambiguous_width,623 initial_indent=initial_indent,624 subsequent_indent=subsequent_indent,625 fix_sentence_endings=fix_sentence_endings,626 break_long_words=break_long_words,627 break_on_hyphens=break_on_hyphens,628 drop_whitespace=drop_whitespace,629 max_lines=max_lines,630 placeholder=placeholder,631 )632 lines = wrapper.wrap(text)633 634 if propagate_sgr:635 lines = _propagate_sgr(lines)636 637 return lines638 