codekingpro/portable-devtools
114k
1"""This is a python implementation of clip()."""2from __future__ import annotations3 4# std imports5import enum6from itertools import islice7 8from typing import Literal, Callable, Optional, NamedTuple9 10# local11from ._width import width12from .grapheme import iter_graphemes13from .hyperlink import Hyperlink, HyperlinkParams14from .sgr_state import (_SGR_STATE_DEFAULT,15 _SGRState,16 _sgr_state_update,17 _sgr_state_is_active,18 _sgr_state_to_sequence)19from .text_sizing import TextSizing, TextSizingParams20from .escape_sequences import (_SEQUENCE_CLASSIFY,21 _HORIZONTAL_CURSOR_MOVEMENT,22 INDETERMINATE_EFFECT_SEQUENCE)23 24 25class _HyperlinkAction(enum.Enum):26 """Outcome of processing an OSC 8 hyperlink unit."""27 28 NO_CLOSE = enum.auto() # open sequence without matching close29 EMPTY = enum.auto() # hyperlink with no visible inner text30 OUTSIDE = enum.auto() # hyperlink entirely outside the clip window31 VISIBLE = enum.auto() # hyperlink overlaps the clip window32 33 34class _HyperlinkResult(NamedTuple):35 """36 Result of processing an OSC 8 hyperlink.37 38 Only the fields relevant to each action are populated.39 """40 41 action: _HyperlinkAction42 close_end: int = 043 inner_width: int = 044 open_seq: str = ''45 clipped_inner: str = ''46 close_seq: str = ''47 clipped_width: int = 048 hl_col_end: int = 049 50 51def _apply_sgr_wrap(result: str, captured_style: Optional[_SGRState]) -> str:52 """53 Apply SGR prefix/suffix around *result*.54 55 If an SGR state was captured at the first visible character, prefix the result with the56 corresponding SGR sequence and suffix with a reset if any styles are active.57 """58 if captured_style is not None:59 if prefix := _sgr_state_to_sequence(captured_style):60 result = prefix + result61 if _sgr_state_is_active(captured_style):62 result += '\x1b[0m'63 return result64 65 66def _process_hyperlink(67 text: str,68 start: int,69 end: int,70 fillchar: str,71 tabsize: int,72 ambiguous_width: int,73 control_codes: Literal['parse', 'strict', 'ignore'],74 *,75 params: HyperlinkParams,76 match_end: int,77 col: int,78) -> _HyperlinkResult:79 """80 Process an OSC 8 hyperlink unit.81 82 Finds the matching close sequence, measures the inner text width, and determines whether the83 hyperlink is empty, outside the clip window, or visible (requiring inner-text clipping).84 """85 # pylint: disable=too-many-locals,too-many-positional-arguments86 close_start, close_end = Hyperlink.find_close(text, match_end)87 if (close_start, close_end) == (-1, -1):88 return _HyperlinkResult(_HyperlinkAction.NO_CLOSE)89 inner_text = text[match_end:close_start]90 inner_width = width(91 inner_text, control_codes=control_codes,92 tabsize=tabsize, ambiguous_width=ambiguous_width,93 )94 95 if inner_width == 0:96 return _HyperlinkResult(_HyperlinkAction.EMPTY, close_end=close_end)97 98 hl_col_end = col + inner_width99 100 if hl_col_end <= start or col >= end:101 return _HyperlinkResult(_HyperlinkAction.OUTSIDE, close_end=close_end,102 inner_width=inner_width)103 104 inner_clip_start = max(0, start - col)105 inner_clip_end = end - col106 107 clipped_inner = clip(108 inner_text, inner_clip_start, inner_clip_end,109 fillchar=fillchar, tabsize=tabsize,110 ambiguous_width=ambiguous_width,111 propagate_sgr=False,112 control_codes=control_codes,113 )114 115 clipped_width = width(116 clipped_inner, control_codes=control_codes,117 tabsize=tabsize, ambiguous_width=ambiguous_width,118 )119 120 return _HyperlinkResult(121 _HyperlinkAction.VISIBLE,122 close_end=close_end,123 inner_width=inner_width,124 open_seq=params.make_open(),125 clipped_inner=clipped_inner,126 close_seq=params.make_close(),127 clipped_width=clipped_width,128 hl_col_end=hl_col_end,129 )130 131 132def _reconstruct_painter(133 cells: dict[int, tuple[str, int]],134 sequences: list[tuple[int, int, str]],135 start: int,136 end: int,137 fillchar: str,138) -> str:139 """140 Reconstruct the output string from painter's algorithm state.141 142 Walks columns left-to-right, interleaving escape sequences and cell content, filling gaps with143 *fillchar*.144 """145 # pylint: disable=too-many-locals146 # Group and sort sequences by column, preserving insertion order within each.147 seqs_by_col: dict[int, list[tuple[int, str]]] = {}148 for col_pos, order, seq_text in sequences:149 seqs_by_col.setdefault(col_pos, []).append((order, seq_text))150 for entries in seqs_by_col.values():151 entries.sort()152 153 max_cell_col = max(cells.keys()) if cells else -1154 max_seq_col = max(seqs_by_col.keys()) if seqs_by_col else -1155 max_col = max(max_cell_col, max_seq_col)156 157 parts: list[str] = []158 walk_col = 0159 col_limit = min(max_col, end)160 while walk_col <= col_limit:161 # Emit any sequences anchored at this column.162 for _, seq_text in seqs_by_col.get(walk_col, ()):163 parts.append(seq_text)164 165 if walk_col >= end:166 walk_col += 1167 continue168 169 if walk_col in cells:170 cell_text, cell_w = cells[walk_col]171 parts.append(cell_text)172 walk_col += cell_w173 else:174 if start <= walk_col <= max_cell_col:175 parts.append(fillchar)176 walk_col += 1177 178 # Emit sequences anchored beyond the visible region.179 for c in sorted(seqs_by_col.keys()):180 if c > col_limit:181 for _, seq_text in seqs_by_col[c]:182 parts.append(seq_text)183 184 return ''.join(parts)185 186 187def _clip_simple(188 text: str,189 start: int,190 end: int,191 *,192 propagate_sgr: bool,193 ambiguous_width: int,194 fillchar: str,195 tabsize: int,196 strict: bool,197 control_codes: Literal['parse', 'strict', 'ignore'],198) -> tuple[str, Optional[_SGRState]]:199 """200 Clip text without cursor movement (simple append-to-output path).201 202 Returns ``(result, captured_style)``. The caller applies SGR wrapping.203 """204 # pylint: disable=too-complex,too-many-locals,too-many-branches,too-many-statements205 # pylint: disable=too-many-nested-blocks206 # code length and complexity traded for performance, to allow this to be used as a "hot path"207 208 output: list[str] = []209 col = 0210 idx = 0211 # captured_style is a frozen snapshot of current_style taken at the first212 # visible character emitted within the clip window (start, end). It stays213 # None until that point. current_style, by contrast, is continuously214 # updated by SGR sequences throughout the scan. The snapshot is what the215 # caller uses to wrap the result in the correct SGR state.216 #217 # When propagate_sgr is False, current_style (and therefore captured_style)218 # remain None, and SGR sequences pass through as literal text.219 captured_style: Optional[_SGRState] = None220 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None221 222 while idx < len(text):223 char = text[idx]224 225 # Early exit: past visible region.226 if col >= end and char not in '\r\x08\t\x1b':227 if captured_style is not None:228 break229 # propagate_sgr is always False here: with propagate_sgr=True,230 # captured_style is set on the first visible emission in the231 # clip window and we would have broken above. The skip-ahead232 # optimization is only needed (and safe) when SGR tracking is off.233 next_esc = text.find('\x1b', idx + 1)234 if next_esc == -1:235 break236 idx = next_esc237 continue238 239 if char == '\x1b':240 m = _SEQUENCE_CLASSIFY.match(text, idx)241 if not m:242 output.append(char)243 idx += 1244 continue245 246 # SGR: update current_style, do not emit.247 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None:248 current_style = _sgr_state_update(current_style, m.group())249 idx = m.end()250 continue251 252 # OSC 8 hyperlink.253 if hl_state := HyperlinkParams.parse(m.group()):254 r = _process_hyperlink(255 text, start, end, fillchar, tabsize, ambiguous_width,256 control_codes,257 params=hl_state, match_end=m.end(), col=col,258 )259 if r.action is _HyperlinkAction.NO_CLOSE:260 output.append(m.group())261 idx = m.end()262 elif r.action is _HyperlinkAction.EMPTY:263 idx = r.close_end264 elif r.action is _HyperlinkAction.OUTSIDE:265 col += r.inner_width266 idx = r.close_end267 else:268 output.append(r.open_seq)269 output.append(r.clipped_inner)270 output.append(r.close_seq)271 if propagate_sgr and captured_style is None:272 captured_style = current_style273 col += r.inner_width274 idx = r.close_end275 continue276 277 # OSC 66 Text Sizing.278 if (ts_meta := m.group('ts_meta')) is not None:279 ts_text = m.group('ts_text')280 ts_term = m.group('ts_term')281 assert ts_text is not None and ts_term is not None282 ts = TextSizing(283 TextSizingParams.from_params(ts_meta, control_codes=control_codes),284 ts_text, ts_term)285 ts_width = ts.display_width(ambiguous_width)286 287 if col >= start and col + ts_width <= end:288 output.append(ts.make_sequence())289 if propagate_sgr and captured_style is None:290 captured_style = current_style291 col += ts_width292 elif col < end and col + ts_width > start:293 ts_parts: list[str] = []294 295 def _ts_write(s: str, _w: int, _col: int) -> None:296 ts_parts.append(s)297 col = _text_sizing_clip(298 ts, col, start, end, fillchar, ambiguous_width,299 _ts_write)300 output.extend(ts_parts)301 if propagate_sgr and captured_style is None:302 captured_style = current_style303 else:304 col += ts_width305 idx = m.end()306 continue307 308 # Indeterminate-effect sequences: raise in strict mode.309 seq = m.group()310 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):311 raise ValueError(312 f"Indeterminate cursor sequence at position {idx}, "313 f"{seq!r}"314 )315 316 # Any other recognized sequence: preserve as-is.317 output.append(seq)318 idx = m.end()319 continue320 321 if char == '\t':322 # Expand tab, filling clip window with spaces.323 if tabsize > 0:324 next_tab = col + (tabsize - (col % tabsize))325 while col < next_tab:326 if start <= col < end:327 output.append(' ')328 if propagate_sgr and captured_style is None:329 captured_style = current_style330 col += 1331 else:332 output.append('\t')333 idx += 1334 continue335 336 grapheme = next(iter_graphemes(text, start=idx))337 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width)338 339 # Emit grapheme or fillchar depending on visibility within clip window.340 if grapheme_w == 0:341 if start <= col < end:342 output.append(grapheme)343 elif col >= start and col + grapheme_w <= end:344 output.append(grapheme)345 if propagate_sgr and captured_style is None:346 captured_style = current_style347 elif col < end and col + grapheme_w > start:348 output.append(fillchar * (min(end, col + grapheme_w) - max(start, col)))349 if propagate_sgr and captured_style is None:350 captured_style = current_style351 352 col += grapheme_w353 idx += len(grapheme)354 355 return ''.join(output), captured_style356 357 358def _text_sizing_clip(359 ts: TextSizing,360 col: int,361 start: int,362 end: int,363 fillchar: str,364 ambiguous_width: int,365 write_cells: Callable[[str, int, int], None],366) -> int:367 """368 Emit tokens for a text-sizing (OSC 66) sequence, clipped to (start, end).369 370 Calls *write_cells(text, width, col)* for each emitted cell or sequence. Returns new column371 position.372 """373 # pylint: disable=too-many-locals,too-many-branches,too-many-positional-arguments,too-complex374 ts_width = ts.display_width(ambiguous_width)375 376 # Fully visible: emit entire sequence377 if col >= start and col + ts_width <= end:378 write_cells(ts.make_sequence(), ts_width, col)379 return col + ts_width380 # Fully outside: just advance column381 if col >= end or col + ts_width <= start:382 return col + ts_width383 384 # Partial overlap: decompose385 rel_start = max(0, start - col)386 rel_end = min(end, col + ts_width) - col387 scale = ts.params.scale388 389 units: list[tuple[str, int]] = []390 if ts.params.width > 0:391 for g in islice(iter_graphemes(ts.text), ts.params.width):392 units.append((g, scale))393 for _ in range(ts.params.width - len(units)):394 units.append(('', scale))395 else:396 for g in iter_graphemes(ts.text):397 units.append((g, width(g, ambiguous_width=ambiguous_width) * scale))398 399 pending_units: list[tuple[str, int]] = []400 401 def flush(flush_col: int) -> None:402 if not pending_units:403 return404 texts = [u[0] for u in pending_units]405 total_w = sum(u[1] for u in pending_units)406 params = TextSizingParams(407 scale,408 len(texts) if ts.params.width > 0 else 0,409 ts.params.numerator, ts.params.denominator,410 ts.params.vertical_align, ts.params.horizontal_align)411 write_cells(412 TextSizing(params, ''.join(texts), ts.terminator).make_sequence(),413 total_w,414 flush_col)415 pending_units.clear()416 417 flush_col_pos = col + rel_start418 unit_pos = 0419 for unit_text, unit_w in units:420 unit_end = unit_pos + unit_w421 if unit_end <= rel_start:422 unit_pos = unit_end423 continue424 if unit_pos >= rel_end:425 break426 427 overlap = min(unit_end, rel_end) - max(unit_pos, rel_start)428 if overlap == unit_w and unit_w > 0:429 if not pending_units:430 flush_col_pos = col + max(unit_pos, rel_start)431 pending_units.append((unit_text, unit_w))432 else:433 flush(flush_col_pos)434 abs_start = col + max(unit_pos, rel_start)435 for i in range(overlap):436 write_cells(fillchar, 1, abs_start + i)437 unit_pos = unit_end438 439 flush(flush_col_pos)440 return col + ts_width441 442 443def _clip_painter(444 text: str,445 start: int,446 end: int,447 *,448 propagate_sgr: bool,449 ambiguous_width: int,450 fillchar: str,451 tabsize: int,452 strict: bool,453 control_codes: Literal['parse', 'strict', 'ignore'],454) -> tuple[str, Optional[_SGRState]]:455 """456 Clip text with cursor movement (painter's algorithm path).457 458 Returns ``(result, captured_style)``. The caller applies SGR wrapping.459 """460 # pylint: disable=too-complex,too-many-locals,too-many-branches461 # pylint: disable=too-many-statements,too-many-nested-blocks462 # code length and complexity traded for performance, to allow this to be used as a "hot path"463 464 cells: dict[int, tuple[str, int]] = {}465 hyperlink_cells: set[int] = set()466 sequences: list[tuple[int, int, str]] = []467 seq_order = 0468 469 col = 0470 idx = 0471 # captured_style is a frozen snapshot of current_style taken at the first472 # visible character emitted within the clip window (start, end). It stays473 # None until that point. current_style, by contrast, is continuously474 # updated by SGR sequences throughout the scan.475 #476 # When propagate_sgr is False, current_style (and therefore captured_style)477 # remain None, and SGR sequences pass through as literal text.478 captured_style: Optional[_SGRState] = None479 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None480 481 def _write_cells(s: str, w: int, write_col: int,482 is_hyperlink: bool = False) -> None:483 """Write *w* cells of text *s* at *write_col*, handling wide-char splitting."""484 nonlocal captured_style485 for offset in range(w):486 src_col = write_col + offset487 if src_col > 0 and cells.get(src_col - 1, ('', 0))[1] == 2:488 cells[src_col - 1] = (fillchar, 1)489 hyperlink_cells.discard(src_col - 1)490 if cells.get(src_col, ('', 0))[1] == 2:491 cells[src_col + 1] = (fillchar, 1)492 hyperlink_cells.discard(src_col + 1)493 cells.pop(src_col, None)494 hyperlink_cells.discard(src_col)495 cells[write_col] = (s, w)496 if is_hyperlink:497 for offset in range(w):498 hyperlink_cells.add(write_col + offset)499 if propagate_sgr and captured_style is None:500 captured_style = current_style501 502 while idx < len(text):503 char = text[idx]504 505 # Early exit: past visible region, SGR captured, no escape ahead.506 if col >= end and captured_style is not None and char != '\x1b':507 break508 509 if char == '\x1b':510 m = _SEQUENCE_CLASSIFY.match(text, idx)511 if not m:512 # Record lone ESC as a zero-width sequence at current column.513 sequences.append((col, seq_order, char))514 seq_order += 1515 if propagate_sgr and captured_style is None:516 captured_style = current_style517 idx += 1518 continue519 520 # SGR: update current_style, do not emit.521 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None:522 current_style = _sgr_state_update(current_style, m.group())523 idx = m.end()524 continue525 526 # OSC 8 hyperlink.527 if hl_state := HyperlinkParams.parse(m.group()):528 r = _process_hyperlink(529 text, start, end, fillchar, tabsize, ambiguous_width,530 control_codes,531 params=hl_state, match_end=m.end(), col=col,532 )533 if r.action is _HyperlinkAction.NO_CLOSE:534 sequences.append((col, seq_order, m.group()))535 seq_order += 1536 if propagate_sgr and captured_style is None:537 captured_style = current_style538 idx = m.end()539 elif r.action is _HyperlinkAction.EMPTY:540 idx = r.close_end541 elif r.action is _HyperlinkAction.OUTSIDE:542 col += r.inner_width543 idx = r.close_end544 else:545 sequences.append((col, seq_order, r.open_seq))546 seq_order += 1547 if propagate_sgr and captured_style is None:548 captured_style = current_style549 _write_cells(r.clipped_inner, r.clipped_width, col,550 is_hyperlink=True)551 col += r.clipped_width552 sequences.append((col, seq_order, r.close_seq))553 seq_order += 1554 col = r.hl_col_end555 idx = r.close_end556 continue557 558 # OSC 66 Text Sizing.559 if (ts_meta := m.group('ts_meta')) is not None:560 ts_text = m.group('ts_text')561 ts_term = m.group('ts_term')562 assert ts_text is not None and ts_term is not None563 ts = TextSizing(564 TextSizingParams.from_params(ts_meta, control_codes=control_codes),565 ts_text, ts_term)566 col = _text_sizing_clip(567 ts, col, start, end, fillchar, ambiguous_width,568 _write_cells)569 if propagate_sgr and captured_style is None:570 captured_style = current_style571 idx = m.end()572 continue573 574 # Indeterminate-effect sequences: raise in strict mode.575 seq = m.group()576 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):577 raise ValueError(578 f"Indeterminate cursor sequence at position {idx}, "579 f"{seq!r}"580 )581 582 # Horizontal Position Absolute (CSI n G).583 if (hpa_n := m.group('hpa_n')) is not None:584 col = int(hpa_n) - 1 if hpa_n else 0585 idx = m.end()586 continue587 588 # Cursor Forward (CSI n C).589 if (cforward_n := m.group('cforward_n')) is not None:590 n_forward = int(cforward_n) if cforward_n else 1591 move_end = col + n_forward592 if col < end and move_end > start:593 for i in range(max(col, start), min(move_end, end)):594 _write_cells(fillchar, 1, i)595 col = move_end596 idx = m.end()597 continue598 599 # Cursor Backward (CSI n D).600 if (cbackward_n := m.group('cbackward_n')) is not None:601 n_backward = int(cbackward_n) if cbackward_n else 1602 if strict and n_backward > col:603 raise ValueError(604 f"Cursor left movement at position {idx} would move "605 f"{n_backward} cells left from column {col}, "606 f"exceeding string start"607 )608 col = max(0, col - n_backward)609 idx = m.end()610 continue611 612 # Any other recognized sequence: preserve as-is.613 sequences.append((col, seq_order, m.group()))614 seq_order += 1615 if propagate_sgr and captured_style is None:616 captured_style = current_style617 idx = m.end()618 continue619 620 # Carriage return.621 if char == '\r':622 col = 0623 idx += 1624 continue625 626 # Backspace.627 if char == '\x08':628 if col > 0:629 col -= 1630 idx += 1631 continue632 633 # Tab expansion.634 if char == '\t':635 if tabsize > 0:636 next_tab = col + (tabsize - (col % tabsize))637 while col < next_tab:638 if start <= col < end:639 _write_cells(fillchar, 1, col)640 col += 1641 else:642 sequences.append((col, seq_order, '\t'))643 seq_order += 1644 if propagate_sgr and captured_style is None:645 captured_style = current_style646 idx += 1647 continue648 649 # Grapheme cluster.650 grapheme = next(iter_graphemes(text, start=idx))651 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width)652 653 # Emit grapheme or fillchar depending on visibility within clip window.654 if grapheme_w == 0:655 if start <= col < end:656 sequences.append((col, seq_order, grapheme))657 seq_order += 1658 if propagate_sgr and captured_style is None:659 captured_style = current_style660 elif col >= start and col + grapheme_w <= end:661 _write_cells(grapheme, grapheme_w, col)662 elif col < end and col + grapheme_w > start:663 clip_start = max(start, col)664 for offset in range(min(end, col + grapheme_w) - clip_start):665 _write_cells(fillchar, 1, clip_start + offset)666 667 col += grapheme_w668 idx += len(grapheme)669 670 return _reconstruct_painter(cells, sequences, start, end, fillchar), captured_style671 672 673def clip(674 text: str,675 start: int,676 end: int,677 *,678 fillchar: str = ' ',679 tabsize: int = 8,680 ambiguous_width: int = 1,681 propagate_sgr: bool = True,682 control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',683 overtyping: Optional[bool] = None,684) -> str:685 r"""686 Clip text to display columns (start, end) while preserving all terminal sequences.687 688 This function extracts a substring based on visible column positions rather than689 character indices. Terminal escape sequences are preserved in the output since690 they have zero display width. If a wide character (width 2) is split at691 either boundary, it is replaced with ``fillchar``.692 693 TAB characters (``\t``) are expanded to spaces up to the next tab stop,694 controlled by the ``tabsize`` parameter. When cursor movement is detected,695 a "painter's algorithm" is used, cursor movements actively change the write696 position, allowing cursor-left and carriage return to overwrite previously697 written cells. It is assumed that ``text`` begins at column 0.698 699 **OSC 8 hyperlinks** are handled specially: the visible text inside a hyperlink700 is clipped to the requested column range, and the hyperlink is rebuilt around701 the clipped text. Empty hyperlinks (those with no remaining visible text after702 clipping) are removed::703 704 >>> clip('\x1b]8;;http://example.com\x07Click This link\x1b]8;;\x07', 6, 10)705 '\x1b]8;;http://example.com\x07This\x1b]8;;\x07'706 707 :param text: String to clip, may contain terminal escape sequences.708 :param start: Absolute starting column (inclusive, 0-indexed).709 :param end: Absolute ending column (exclusive).710 :param fillchar: Character to use when a wide character must be split at711 a boundary (default space). Must have display width of 1.712 :param tabsize: Tab stop width (default 8). Set to 0 to pass tabs through713 as zero-width (preserved in output but don't advance column position).714 :param ambiguous_width: Width to use for East Asian Ambiguous (A)715 characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.716 :param propagate_sgr: If True (default), SGR (terminal styling) sequences717 are propagated. The result begins with any active style at the start718 position and ends with a reset sequence if styles are active.719 :param control_codes: How to handle control characters and sequences:720 721 - ``'parse'`` (default): Track horizontal cursor movement and clip722 hyperlink text. Cursor overwrite is always allowed, with best effort723 results; indeterminate sequences (home, clear, reset, etc.) are724 preserved as zero-width.725 - ``'strict'``: Like ``parse``, but raises :exc:`ValueError` on726 sequences with indeterminate effects (cursor home, clear screen,727 reset, vertical movement, etc.) matching :func:`width` behavior.728 Also raises on out-of-bounds horizontal cursor movement.729 - ``'ignore'``: All control characters are treated as zero-width.730 Cursor movement is not tracked (fastest path).731 732 :param overtyping: Whether to use the painter's algorithm for cursor733 movement (``\b`` backspace, ``\r`` carriage return, and CSI cursor734 left/right/position sequences). When ``None`` (default), auto-detects735 by scanning for these characters in *text*. Set to ``False`` for improved736 performance when the caller knows *text* contains no cursor movement737 characters. Set to ``True`` to force the painter's algorithm (useful738 for testing). Has no effect when ``control_codes='ignore'``.739 740 :returns: Substring of ``text`` spanning display columns (start, end),741 with all terminal sequences preserved and wide characters at boundaries742 replaced with ``fillchar``.743 744 :raises ValueError: If ``control_codes='strict'`` and an indeterminate-effect745 sequence or out-of-bounds cursor movement is encountered.746 747 SGR (terminal styling) sequences are propagated by default. The result748 begins with any active style and ends with a reset::749 750 >>> clip('\x1b[1;34mHello world\x1b[0m', 6, 11)751 '\x1b[1;34mworld\x1b[0m'752 753 Set ``propagate_sgr=False`` to disable this behavior.754 755 .. versionadded:: 0.3.0756 757 .. versionchanged:: 0.5.0758 Added ``propagate_sgr`` parameter (default True).759 760 .. versionchanged:: 0.7.0761 Added ``control_codes`` parameter (default 'parse').762 OSC 8 hyperlink-aware clipping. OSC 66 text sizing protocol support.763 Added ``overtyping`` parameter (default None, auto-detect).764 765 Example::766 767 >>> clip('hello world', 0, 5)768 'hello'769 >>> clip('中文字', 0, 3) # Wide char split at column 3770 '中 '771 >>> clip('a\tb', 0, 10) # Tab expanded to spaces772 'a b'773 """774 start = max(start, 0)775 if end <= start:776 return ''777 778 # Fast path: printable ASCII only.779 if text.isascii() and text.isprintable():780 return text[start:end]781 782 # No escape sequences => no SGR tracking needed.783 has_esc = '\x1b' in text784 if propagate_sgr and not has_esc:785 propagate_sgr = False786 787 # Determine whether painter's algorithm is needed.788 if overtyping is None:789 # Auto-detect: scan for cursor movement characters.790 overtyping = (791 control_codes != 'ignore' and792 ('\x08' in text or '\r' in text or793 (has_esc and bool(_HORIZONTAL_CURSOR_MOVEMENT.search(text))))794 )795 elif overtyping and control_codes == 'ignore':796 overtyping = False # control_codes='ignore' overrides797 fn_clip = _clip_painter if overtyping else _clip_simple798 799 return _apply_sgr_wrap(*fn_clip(800 text=text,801 start=start,802 end=end,803 propagate_sgr=propagate_sgr,804 ambiguous_width=ambiguous_width,805 fillchar=fillchar,806 tabsize=tabsize,807 strict=(control_codes == 'strict'),808 control_codes=control_codes,809 ))810 