Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wcwidth/_clip.py: 8%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""This is a python implementation of clip()."""
2from __future__ import annotations
4# std imports
5import enum
6from itertools import islice
8from typing import Literal, Callable, Optional, NamedTuple
10# local
11from ._width import width
12from .grapheme import iter_graphemes
13from .hyperlink import Hyperlink, HyperlinkParams
14from .sgr_state import (_SGR_PATTERN,
15 _SGR_STATE_DEFAULT,
16 _SGRState,
17 _sgr_state_update,
18 _sgr_state_is_active,
19 _sgr_state_to_sequence)
20from .text_sizing import TextSizing, TextSizingParams
21from .escape_sequences import (_SEQUENCE_CLASSIFY,
22 _HORIZONTAL_CURSOR_MOVEMENT,
23 INDETERMINATE_EFFECT_SEQUENCE)
26class _HyperlinkAction(enum.Enum):
27 """Outcome of processing an OSC 8 hyperlink unit."""
29 NO_CLOSE = enum.auto() # open sequence without matching close
30 EMPTY = enum.auto() # hyperlink with no visible inner text
31 OUTSIDE = enum.auto() # hyperlink entirely outside the clip window
32 VISIBLE = enum.auto() # hyperlink overlaps the clip window
35class _HyperlinkResult(NamedTuple):
36 """
37 Result of processing an OSC 8 hyperlink.
39 Only the fields relevant to each action are populated.
40 """
42 action: _HyperlinkAction
43 close_end: int = 0
44 inner_width: int = 0
45 open_seq: str = ''
46 clipped_inner: str = ''
47 close_seq: str = ''
48 clipped_width: int = 0
49 hl_col_end: int = 0
52def _apply_sgr_wrap(result: str, captured_style: Optional[_SGRState],
53 end_style: Optional[_SGRState] = None) -> str:
54 """
55 Apply SGR prefix/suffix around *result*.
57 If an SGR state was captured at the first visible character, prefix the result with the
58 corresponding SGR sequence, and suffix with a reset if any styles remain active at the end
59 of the clipped region.
61 *end_style* is the style in effect after the final SGR sequence emitted within the clip window,
62 or ``None`` when no such sequence was emitted, the style at the first visible character is still
63 in effect. This matches :func:`wcwidth.propagate_sgr`, which decides the trailing reset by SGR
64 state at the end of each line.
65 """
66 if captured_style is not None:
67 if prefix := _sgr_state_to_sequence(captured_style):
68 result = prefix + result
69 if _sgr_state_is_active(captured_style if end_style is None else end_style):
70 result += '\x1b[0m'
71 return result
74def _process_hyperlink(
75 text: str,
76 start: int,
77 end: int,
78 fillchar: str,
79 tabsize: int,
80 ambiguous_width: int,
81 term_program: bool | str,
82 control_codes: Literal['parse', 'strict', 'ignore'],
83 *,
84 params: HyperlinkParams,
85 match_end: int,
86 col: int,
87) -> _HyperlinkResult:
88 """
89 Process an OSC 8 hyperlink unit.
91 Finds the matching close sequence, measures the inner text width, and determines whether the
92 hyperlink is empty, outside the clip window, or visible (requiring inner-text clipping).
93 """
94 # pylint: disable=too-many-locals,too-many-positional-arguments,too-many-arguments
95 close_start, close_end = Hyperlink.find_close(text, match_end)
96 if (close_start, close_end) == (-1, -1):
97 return _HyperlinkResult(_HyperlinkAction.NO_CLOSE)
98 inner_text = text[match_end:close_start]
99 inner_width = width(
100 inner_text, control_codes=control_codes,
101 tabsize=tabsize, ambiguous_width=ambiguous_width,
102 term_program=term_program,
103 )
105 if inner_width == 0:
106 return _HyperlinkResult(_HyperlinkAction.EMPTY, close_end=close_end)
108 hl_col_end = col + inner_width
110 if hl_col_end <= start or col >= end:
111 return _HyperlinkResult(_HyperlinkAction.OUTSIDE, close_end=close_end,
112 inner_width=inner_width)
114 inner_clip_start = max(0, start - col)
115 inner_clip_end = end - col
117 clipped_inner = clip(
118 inner_text, inner_clip_start, inner_clip_end,
119 fillchar=fillchar, tabsize=tabsize,
120 ambiguous_width=ambiguous_width,
121 term_program=term_program,
122 propagate_sgr=False,
123 control_codes=control_codes,
124 )
126 clipped_width = width(
127 clipped_inner, control_codes=control_codes,
128 tabsize=tabsize, ambiguous_width=ambiguous_width,
129 term_program=term_program,
130 )
132 return _HyperlinkResult(
133 _HyperlinkAction.VISIBLE,
134 close_end=close_end,
135 inner_width=inner_width,
136 open_seq=params.make_open(),
137 clipped_inner=clipped_inner,
138 close_seq=params.make_close(),
139 clipped_width=clipped_width,
140 hl_col_end=hl_col_end,
141 )
144def _reconstruct_painter(
145 cells: dict[int, tuple[str, int]],
146 sequences: list[tuple[int, int, str]],
147 start: int,
148 end: int,
149 fillchar: str,
150) -> str:
151 """
152 Reconstruct the output string from painter's algorithm state.
154 Walks columns left-to-right, interleaving escape sequences and cell content, filling gaps with
155 *fillchar*.
156 """
157 # pylint: disable=too-many-locals
158 # Group and sort sequences by column, preserving insertion order within each.
159 seqs_by_col: dict[int, list[tuple[int, str]]] = {}
160 for col_pos, order, seq_text in sequences:
161 seqs_by_col.setdefault(col_pos, []).append((order, seq_text))
162 for entries in seqs_by_col.values():
163 entries.sort()
165 max_cell_col = max(cells.keys()) if cells else -1
166 max_seq_col = max(seqs_by_col.keys()) if seqs_by_col else -1
167 max_col = max(max_cell_col, max_seq_col)
169 parts: list[str] = []
170 walk_col = 0
171 col_limit = min(max_col, end)
172 while walk_col <= col_limit:
173 # Emit any sequences anchored at this column.
174 for _, seq_text in seqs_by_col.get(walk_col, ()):
175 parts.append(seq_text)
177 if walk_col >= end:
178 walk_col += 1
179 continue
181 if walk_col in cells:
182 cell_text, cell_w = cells[walk_col]
183 parts.append(cell_text)
184 walk_col += cell_w
185 else:
186 if start <= walk_col <= max_cell_col:
187 parts.append(fillchar)
188 walk_col += 1
190 # Emit sequences anchored beyond the visible region.
191 for c in sorted(seqs_by_col.keys()):
192 if c > col_limit:
193 for _, seq_text in seqs_by_col[c]:
194 parts.append(seq_text)
196 return ''.join(parts)
199def _clip_simple(
200 text: str,
201 start: int,
202 end: int,
203 *,
204 propagate_sgr: bool,
205 ambiguous_width: int,
206 term_program: bool | str,
207 fillchar: str,
208 tabsize: int,
209 strict: bool,
210 control_codes: Literal['parse', 'strict', 'ignore'],
211) -> tuple[str, Optional[_SGRState], Optional[_SGRState]]:
212 """
213 Clip text without cursor movement (simple append-to-output path).
215 Returns ``(result, captured_style, end_style)``. The caller applies SGR wrapping.
216 """
217 # pylint: disable=too-complex,too-many-locals,too-many-branches,too-many-statements
218 # pylint: disable=too-many-nested-blocks
219 # code length and complexity traded for performance, to allow this to be used as a "hot path"
221 output: list[str] = []
222 col = 0
223 idx = 0
224 # captured_style is a frozen snapshot of current_style taken at the first
225 # visible character emitted within the clip window (start, end). It stays
226 # None until that point. current_style, by contrast, is continuously
227 # updated by SGR sequences throughout the scan. The snapshot is what the
228 # caller uses to wrap the result in the correct SGR state.
229 #
230 # When propagate_sgr is False, current_style (and therefore captured_style)
231 # remain None, and SGR sequences pass through as literal text.
232 captured_style: Optional[_SGRState] = None
233 # end_style is the state after the last SGR sequence emitted *within* the
234 # clip window; it decides the trailing reset. None until such a sequence
235 # is emitted, meaning captured_style is still in effect at the end.
236 end_style: Optional[_SGRState] = None
237 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None
239 while idx < len(text):
240 char = text[idx]
242 # Early exit: past visible region.
243 if col >= end and char not in '\r\x08\t\x1b':
244 if captured_style is not None:
245 break
246 # propagate_sgr is always False here: with propagate_sgr=True,
247 # captured_style is set on the first visible emission in the
248 # clip window and we would have broken above. The skip-ahead
249 # optimization is only needed (and safe) when SGR tracking is off.
250 next_esc = text.find('\x1b', idx + 1)
251 if next_esc == -1:
252 break
253 idx = next_esc
254 continue
256 if char == '\x1b':
257 m = _SEQUENCE_CLASSIFY.match(text, idx)
258 if not m:
259 output.append(char)
260 idx += 1
261 continue
263 # SGR: update current_style. Sequences before the first visible
264 # emission are folded into the prefix synthesized by
265 # _apply_sgr_wrap(); those inside the clip window are emitted at
266 # their original position; those at or beyond *end* are dropped.
267 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None:
268 current_style = _sgr_state_update(current_style, m.group())
269 if captured_style is not None and col < end:
270 output.append(m.group())
271 end_style = current_style
272 idx = m.end()
273 continue
275 # OSC 8 hyperlink.
276 if hl_state := HyperlinkParams.parse(m.group()):
277 r = _process_hyperlink(
278 text, start, end, fillchar, tabsize, ambiguous_width,
279 term_program,
280 control_codes,
281 params=hl_state, match_end=m.end(), col=col,
282 )
283 if r.action is _HyperlinkAction.NO_CLOSE:
284 output.append(m.group())
285 idx = m.end()
286 elif r.action is _HyperlinkAction.EMPTY:
287 idx = r.close_end
288 elif r.action is _HyperlinkAction.OUTSIDE:
289 col += r.inner_width
290 idx = r.close_end
291 else:
292 output.append(r.open_seq)
293 output.append(r.clipped_inner)
294 output.append(r.close_seq)
295 if propagate_sgr and captured_style is None:
296 captured_style = current_style
297 # Inner text is clipped with propagate_sgr=False, so any SGR
298 # sequences it emits are verbatim: fold them into our state.
299 if propagate_sgr and current_style is not None:
300 for sgr_m in _SGR_PATTERN.finditer(r.clipped_inner):
301 current_style = _sgr_state_update(current_style, sgr_m.group())
302 end_style = current_style
303 col += r.inner_width
304 idx = r.close_end
305 continue
307 # OSC 66 Text Sizing.
308 if (ts_meta := m.group('ts_meta')) is not None:
309 ts_text = m.group('ts_text')
310 ts_term = m.group('ts_term')
311 assert ts_text is not None and ts_term is not None
312 ts = TextSizing(
313 TextSizingParams.from_params(ts_meta, control_codes=control_codes),
314 ts_text, ts_term)
315 ts_width = ts.display_width(ambiguous_width)
317 if col >= start and col + ts_width <= end:
318 output.append(ts.make_sequence())
319 if propagate_sgr and captured_style is None:
320 captured_style = current_style
321 col += ts_width
322 elif col < end and col + ts_width > start:
323 ts_parts: list[str] = []
325 def _ts_write(s: str, _w: int, _col: int) -> None:
326 ts_parts.append(s)
327 col = _text_sizing_clip(
328 ts, col, start, end, fillchar, ambiguous_width,
329 term_program,
330 _ts_write)
331 output.extend(ts_parts)
332 if propagate_sgr and captured_style is None:
333 captured_style = current_style
334 else:
335 col += ts_width
336 idx = m.end()
337 continue
339 # Indeterminate-effect sequences: raise in strict mode.
340 seq = m.group()
341 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):
342 raise ValueError(
343 f"Indeterminate cursor sequence at position {idx}, "
344 f"{seq!r}"
345 )
347 # Any other recognized sequence: preserve as-is.
348 output.append(seq)
349 idx = m.end()
350 continue
352 if char == '\t':
353 # Expand tab, filling clip window with spaces.
354 if tabsize > 0:
355 next_tab = col + (tabsize - (col % tabsize))
356 while col < next_tab:
357 if start <= col < end:
358 output.append(' ')
359 if propagate_sgr and captured_style is None:
360 captured_style = current_style
361 col += 1
362 else:
363 output.append('\t')
364 idx += 1
365 continue
367 grapheme = next(iter_graphemes(text, start=idx))
368 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width,
369 term_program=term_program)
371 # Emit grapheme or fillchar depending on visibility within clip window.
372 if grapheme_w == 0:
373 if start <= col < end:
374 output.append(grapheme)
375 elif col >= start and col + grapheme_w <= end:
376 output.append(grapheme)
377 if propagate_sgr and captured_style is None:
378 captured_style = current_style
379 elif col < end and col + grapheme_w > start:
380 output.append(fillchar * (min(end, col + grapheme_w) - max(start, col)))
381 if propagate_sgr and captured_style is None:
382 captured_style = current_style
384 col += grapheme_w
385 idx += len(grapheme)
387 return ''.join(output), captured_style, end_style
390def _text_sizing_clip(
391 ts: TextSizing,
392 col: int,
393 start: int,
394 end: int,
395 fillchar: str,
396 ambiguous_width: int,
397 term_program: bool | str,
398 write_cells: Callable[[str, int, int], None],
399) -> int:
400 """
401 Emit tokens for a text-sizing (OSC 66) sequence, clipped to (start, end).
403 Calls *write_cells(text, width, col)* for each emitted cell or sequence. Returns new column
404 position.
405 """
406 # pylint: disable=too-many-locals,too-many-branches,too-many-positional-arguments,too-complex
407 ts_width = ts.display_width(ambiguous_width)
409 # Fully visible: emit entire sequence
410 if col >= start and col + ts_width <= end:
411 write_cells(ts.make_sequence(), ts_width, col)
412 return col + ts_width
413 # Fully outside: just advance column
414 if col >= end or col + ts_width <= start:
415 return col + ts_width
417 # Partial overlap: decompose
418 rel_start = max(0, start - col)
419 rel_end = min(end, col + ts_width) - col
420 scale = ts.params.scale
422 units: list[tuple[str, int]] = []
423 if ts.params.width > 0:
424 for g in islice(iter_graphemes(ts.text), ts.params.width):
425 units.append((g, scale))
426 for _ in range(ts.params.width - len(units)):
427 units.append(('', scale))
428 else:
429 for g in iter_graphemes(ts.text):
430 units.append(
431 (g, width(g, ambiguous_width=ambiguous_width,
432 term_program=term_program) * scale))
434 pending_units: list[tuple[str, int]] = []
436 def flush(flush_col: int) -> None:
437 if not pending_units:
438 return
439 texts = [u[0] for u in pending_units]
440 total_w = sum(u[1] for u in pending_units)
441 params = TextSizingParams(
442 scale,
443 len(texts) if ts.params.width > 0 else 0,
444 ts.params.numerator, ts.params.denominator,
445 ts.params.vertical_align, ts.params.horizontal_align)
446 write_cells(
447 TextSizing(params, ''.join(texts), ts.terminator).make_sequence(),
448 total_w,
449 flush_col)
450 pending_units.clear()
452 flush_col_pos = col + rel_start
453 unit_pos = 0
454 for unit_text, unit_w in units:
455 unit_end = unit_pos + unit_w
456 if unit_end <= rel_start:
457 unit_pos = unit_end
458 continue
459 if unit_pos >= rel_end:
460 break
462 overlap = min(unit_end, rel_end) - max(unit_pos, rel_start)
463 if overlap == unit_w and unit_w > 0:
464 if not pending_units:
465 flush_col_pos = col + max(unit_pos, rel_start)
466 pending_units.append((unit_text, unit_w))
467 else:
468 flush(flush_col_pos)
469 abs_start = col + max(unit_pos, rel_start)
470 for i in range(overlap):
471 write_cells(fillchar, 1, abs_start + i)
472 unit_pos = unit_end
474 flush(flush_col_pos)
475 return col + ts_width
478def _clip_painter(
479 text: str,
480 start: int,
481 end: int,
482 *,
483 propagate_sgr: bool,
484 ambiguous_width: int,
485 term_program: bool | str,
486 fillchar: str,
487 tabsize: int,
488 strict: bool,
489 control_codes: Literal['parse', 'strict', 'ignore'],
490) -> tuple[str, Optional[_SGRState], Optional[_SGRState]]:
491 """
492 Clip text with cursor movement (painter's algorithm path).
494 Returns ``(result, captured_style, end_style)``. The caller applies SGR wrapping.
495 """
496 # pylint: disable=too-complex,too-many-locals,too-many-branches
497 # pylint: disable=too-many-statements,too-many-nested-blocks
498 # code length and complexity traded for performance, to allow this to be used as a "hot path"
500 cells: dict[int, tuple[str, int]] = {}
501 hyperlink_cells: set[int] = set()
502 sequences: list[tuple[int, int, str]] = []
503 seq_order = 0
505 col = 0
506 idx = 0
507 # captured_style is a frozen snapshot of current_style taken at the first
508 # visible character emitted within the clip window (start, end). It stays
509 # None until that point. current_style, by contrast, is continuously
510 # updated by SGR sequences throughout the scan.
511 #
512 # When propagate_sgr is False, current_style (and therefore captured_style)
513 # remain None, and SGR sequences pass through as literal text.
514 captured_style: Optional[_SGRState] = None
515 # end_style is the state after the last SGR sequence emitted *within* the
516 # clip window; it decides the trailing reset. None until such a sequence
517 # is emitted, meaning captured_style is still in effect at the end.
518 end_style: Optional[_SGRState] = None
519 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None
521 def _write_cells(s: str, w: int, write_col: int,
522 is_hyperlink: bool = False) -> None:
523 """Write *w* cells of text *s* at *write_col*, handling wide-char splitting."""
524 nonlocal captured_style
525 for offset in range(w):
526 src_col = write_col + offset
527 if src_col > 0 and cells.get(src_col - 1, ('', 0))[1] == 2:
528 cells[src_col - 1] = (fillchar, 1)
529 hyperlink_cells.discard(src_col - 1)
530 if cells.get(src_col, ('', 0))[1] == 2:
531 cells[src_col + 1] = (fillchar, 1)
532 hyperlink_cells.discard(src_col + 1)
533 cells.pop(src_col, None)
534 hyperlink_cells.discard(src_col)
535 cells[write_col] = (s, w)
536 if is_hyperlink:
537 for offset in range(w):
538 hyperlink_cells.add(write_col + offset)
539 if propagate_sgr and captured_style is None:
540 captured_style = current_style
542 while idx < len(text):
543 char = text[idx]
545 # Early exit: past visible region, SGR captured, no escape ahead.
546 if col >= end and captured_style is not None and char != '\x1b':
547 break
549 if char == '\x1b':
550 m = _SEQUENCE_CLASSIFY.match(text, idx)
551 if not m:
552 # Record lone ESC as a zero-width sequence at current column.
553 sequences.append((col, seq_order, char))
554 seq_order += 1
555 idx += 1
556 continue
558 # SGR: update current_style. Sequences before the first visible
559 # emission are folded into the prefix synthesized by
560 # _apply_sgr_wrap(); those inside the clip window are emitted at
561 # their original position; those at or beyond *end* are dropped.
562 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None:
563 current_style = _sgr_state_update(current_style, m.group())
564 if captured_style is not None and col < end:
565 sequences.append((col, seq_order, m.group()))
566 seq_order += 1
567 end_style = current_style
568 idx = m.end()
569 continue
571 # OSC 8 hyperlink.
572 if hl_state := HyperlinkParams.parse(m.group()):
573 r = _process_hyperlink(
574 text, start, end, fillchar, tabsize, ambiguous_width,
575 term_program,
576 control_codes,
577 params=hl_state, match_end=m.end(), col=col,
578 )
579 if r.action is _HyperlinkAction.NO_CLOSE:
580 sequences.append((col, seq_order, m.group()))
581 seq_order += 1
582 idx = m.end()
583 elif r.action is _HyperlinkAction.EMPTY:
584 idx = r.close_end
585 elif r.action is _HyperlinkAction.OUTSIDE:
586 col += r.inner_width
587 idx = r.close_end
588 else:
589 sequences.append((col, seq_order, r.open_seq))
590 seq_order += 1
591 _write_cells(r.clipped_inner, r.clipped_width, col,
592 is_hyperlink=True)
593 col += r.clipped_width
594 sequences.append((col, seq_order, r.close_seq))
595 seq_order += 1
596 # Inner text is clipped with propagate_sgr=False, so any SGR
597 # sequences it emits are verbatim: fold them into our state.
598 if propagate_sgr and current_style is not None:
599 for sgr_m in _SGR_PATTERN.finditer(r.clipped_inner):
600 current_style = _sgr_state_update(current_style, sgr_m.group())
601 end_style = current_style
602 col = r.hl_col_end
603 idx = r.close_end
604 continue
606 # OSC 66 Text Sizing.
607 if (ts_meta := m.group('ts_meta')) is not None:
608 ts_text = m.group('ts_text')
609 ts_term = m.group('ts_term')
610 assert ts_text is not None and ts_term is not None
611 ts = TextSizing(
612 TextSizingParams.from_params(ts_meta, control_codes=control_codes),
613 ts_text, ts_term)
614 col = _text_sizing_clip(
615 ts, col, start, end, fillchar, ambiguous_width,
616 term_program,
617 _write_cells)
618 idx = m.end()
619 continue
621 # Indeterminate-effect sequences: raise in strict mode.
622 seq = m.group()
623 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):
624 raise ValueError(
625 f"Indeterminate cursor sequence at position {idx}, "
626 f"{seq!r}"
627 )
629 # Horizontal Position Absolute (CSI n G).
630 if (hpa_n := m.group('hpa_n')) is not None:
631 col = int(hpa_n) - 1 if hpa_n else 0
632 idx = m.end()
633 continue
635 # Cursor Forward (CSI n C).
636 if (cforward_n := m.group('cforward_n')) is not None:
637 n_forward = int(cforward_n) if cforward_n else 1
638 move_end = col + n_forward
639 if col < end and move_end > start:
640 for i in range(max(col, start), min(move_end, end)):
641 _write_cells(fillchar, 1, i)
642 col = move_end
643 idx = m.end()
644 continue
646 # Cursor Backward (CSI n D).
647 if (cbackward_n := m.group('cbackward_n')) is not None:
648 n_backward = int(cbackward_n) if cbackward_n else 1
649 if strict and n_backward > col:
650 raise ValueError(
651 f"Cursor left movement at position {idx} would move "
652 f"{n_backward} cells left from column {col}, "
653 f"exceeding string start"
654 )
655 col -= n_backward
656 if col < 0:
657 col = 0
658 idx = m.end()
659 continue
661 # Any other recognized sequence: preserve as-is.
662 sequences.append((col, seq_order, m.group()))
663 seq_order += 1
664 idx = m.end()
665 continue
667 # Carriage return.
668 if char == '\r':
669 col = 0
670 idx += 1
671 continue
673 # Backspace.
674 if char == '\x08':
675 if col > 0:
676 col -= 1
677 idx += 1
678 continue
680 # Tab expansion.
681 if char == '\t':
682 if tabsize > 0:
683 next_tab = col + (tabsize - (col % tabsize))
684 while col < next_tab:
685 if start <= col < end:
686 _write_cells(fillchar, 1, col)
687 col += 1
688 else:
689 sequences.append((col, seq_order, '\t'))
690 seq_order += 1
691 idx += 1
692 continue
694 # Grapheme cluster.
695 grapheme = next(iter_graphemes(text, start=idx))
696 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width,
697 term_program=term_program)
699 # Emit grapheme or fillchar depending on visibility within clip window.
700 if grapheme_w == 0:
701 if start <= col < end:
702 sequences.append((col, seq_order, grapheme))
703 seq_order += 1
704 elif col >= start and col + grapheme_w <= end:
705 _write_cells(grapheme, grapheme_w, col)
706 elif col < end and col + grapheme_w > start:
707 clip_start = max(start, col)
708 for offset in range(min(end, col + grapheme_w) - clip_start):
709 _write_cells(fillchar, 1, clip_start + offset)
711 col += grapheme_w
712 idx += len(grapheme)
714 return (_reconstruct_painter(cells, sequences, start, end, fillchar),
715 captured_style, end_style)
718def clip(
719 text: str,
720 start: int,
721 end: int,
722 *,
723 fillchar: str = ' ',
724 tabsize: int = 8,
725 ambiguous_width: int = 1,
726 propagate_sgr: bool = True,
727 control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
728 overtyping: Optional[bool] = None,
729 term_program: bool | str = False,
730) -> str:
731 r"""
732 Clip text to display columns (start, end) while preserving all terminal sequences.
734 This function extracts a substring based on visible column positions rather than
735 character indices. Terminal escape sequences are preserved in the output since
736 they have zero display width. If a wide character (width 2) is split at
737 either boundary, it is replaced with ``fillchar``.
739 TAB characters (``\t``) are expanded to spaces up to the next tab stop,
740 controlled by the ``tabsize`` parameter.
742 When cursor movement is detected, a "painter's algorithm" is used unless ``overtyping=False`` is
743 set. Cursor movement control codes are parsed for their effects instead of ignored. For all
744 such operations, it is assumed that ``text`` begins at column 0.
746 **OSC 8 hyperlinks** are handled specially: the visible text inside a hyperlink
747 is clipped to the requested column range, and the hyperlink is rebuilt around
748 the clipped text. Empty hyperlinks (those with no remaining visible text after
749 clipping) are removed::
751 >>> clip('\x1b]8;;http://example.com\x07Click This link\x1b]8;;\x07', 6, 10)
752 '\x1b]8;;http://example.com\x07This\x1b]8;;\x07'
754 :param text: String to clip, may contain terminal escape sequences.
755 :param start: Absolute starting column (inclusive, 0-indexed).
756 :param end: Absolute ending column (exclusive).
757 :param fillchar: Character to use when a wide character must be split at
758 a boundary (default space). Must have display width of 1.
759 :param tabsize: Tab stop width (default 8). Set to 0 to pass tabs through
760 as zero-width (preserved in output but don't advance column position).
761 :param ambiguous_width: Width to use for East Asian Ambiguous (A)
762 characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
763 :param propagate_sgr: If True (default), SGR (terminal styling) sequences
764 are propagated, matching :func:`propagate_sgr`. The result begins with
765 any style active at the start position, retains any style changes
766 occurring within the clipped region at their original position, and
767 ends with a reset sequence if styles are active at the end position.
768 :param control_codes: How to handle control characters and sequences:
770 - ``'parse'`` (default): Track horizontal cursor movement and clip
771 hyperlink text. Cursor overwrite is always allowed, with best effort
772 results; indeterminate sequences (home, clear, reset, etc.) are
773 preserved as zero-width.
774 - ``'strict'``: Like ``parse``, but raises :exc:`ValueError` on
775 sequences with indeterminate effects (cursor home, clear screen,
776 reset, vertical movement, etc.) matching :func:`width` behavior.
777 Also raises on out-of-bounds horizontal cursor movement.
778 - ``'ignore'``: All control characters are treated as zero-width.
779 Cursor movement is not tracked (fastest path).
781 :param overtyping: Whether to use the painter's algorithm for cursor
782 movement (``\b`` backspace, ``\r`` carriage return, and CSI cursor
783 left/right/position sequences). When ``None`` (default), auto-detects
784 by scanning for these characters in *text*. Set to ``False`` for improved
785 performance when the caller knows *text* contains no cursor movement
786 characters. Set to ``True`` to force the painter's algorithm (useful
787 for testing). Has no effect when ``control_codes='ignore'``.
788 :param term_program: Terminal software identifier for table correction.
789 ``False`` (default) disables override lookup. ``True`` reads the
790 ``TERM_PROGRAM`` or ``TERM`` environment variable for auto-detection.
791 Accepts a canonical terminal name matching :func:`list_term_programs`,
792 such as from XTVERSION_, ENQ_, or ``TERM_PROGRAM``.
794 .. versionadded:: 0.8.0
796 :returns: Substring of ``text`` spanning display columns (start, end),
797 with all terminal sequences preserved and wide characters at boundaries
798 replaced with ``fillchar``.
800 :raises ValueError: If ``control_codes='strict'`` and an indeterminate-effect
801 sequence or out-of-bounds cursor movement is encountered.
803 SGR (terminal styling) sequences are propagated by default. The result
804 begins with any active style and ends with a reset::
806 >>> clip('\x1b[1;34mHello world\x1b[0m', 6, 11)
807 '\x1b[1;34mworld\x1b[0m'
808 >>> wcwidth.clip('\x1b[1mbold\x1b[m normal', 1, 9)
809 '\x1b[1mold\x1b[m norm'
811 Set ``propagate_sgr=False`` to disable this behavior.
813 .. versionadded:: 0.3.0
815 .. versionchanged:: 0.5.0
816 Added ``propagate_sgr`` parameter (default True).
818 .. versionchanged:: 0.7.0
819 Added ``control_codes`` parameter (default 'parse').
820 OSC 8 hyperlink-aware clipping. OSC 66 text sizing protocol support.
821 Added ``overtyping`` parameter (default None, auto-detect).
823 Example::
825 >>> clip('hello world', 0, 5)
826 'hello'
827 >>> clip('中文字', 0, 3) # Wide char split at column 3
828 '中 '
829 >>> clip('a\tb', 0, 10) # Tab expanded to spaces
830 'a b'
831 """
832 start = max(start, 0)
833 if end <= start:
834 return ''
836 # Fast path: printable ASCII only.
837 if text.isascii() and text.isprintable():
838 return text[start:end]
840 # No escape sequences => no SGR tracking needed.
841 has_esc = '\x1b' in text
842 if propagate_sgr and not has_esc:
843 propagate_sgr = False
845 # Determine whether painter's algorithm is needed.
846 if overtyping is None:
847 # Auto-detect: scan for cursor movement characters.
848 overtyping = (
849 control_codes != 'ignore' and
850 ('\x08' in text or '\r' in text or
851 (has_esc and bool(_HORIZONTAL_CURSOR_MOVEMENT.search(text))))
852 )
853 elif overtyping and control_codes == 'ignore':
854 overtyping = False # control_codes='ignore' overrides
855 fn_clip = _clip_painter if overtyping else _clip_simple
857 return _apply_sgr_wrap(*fn_clip(
858 text=text,
859 start=start,
860 end=end,
861 propagate_sgr=propagate_sgr,
862 ambiguous_width=ambiguous_width,
863 term_program=term_program,
864 fillchar=fillchar,
865 tabsize=tabsize,
866 strict=(control_codes == 'strict'),
867 control_codes=control_codes,
868 ))