Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wcwidth/_clip.py: 9%
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 os
6import sys
7import enum
8from itertools import islice
10from typing import Literal, Callable, Optional, NamedTuple
12# local
13from ._width import width
14from .grapheme import iter_graphemes
15from .hyperlink import Hyperlink, HyperlinkParams
16from .sgr_state import (_SGR_PATTERN,
17 _SGR_STATE_DEFAULT,
18 _SGRState,
19 _sgr_state_update,
20 _sgr_state_is_active,
21 _sgr_state_to_sequence)
22from ._constants import _clamp_ambiguous_width
23from .text_sizing import TextSizing, TextSizingParams
24from .escape_sequences import (_SEQUENCE_CLASSIFY,
25 _HORIZONTAL_CURSOR_MOVEMENT,
26 INDETERMINATE_EFFECT_SEQUENCE)
28_c_clip: Optional[Callable[..., Optional[str]]] # pylint: disable=invalid-name
29if os.environ.get('WCWIDTH_PYTHON', ''):
30 _c_clip = None
31else:
32 try:
33 # local
34 from ._wcwidth_c import clip as _c_clip_impl
35 except ImportError:
36 _c_clip = None
37 else:
38 _c_clip = _c_clip_impl
41class _HyperlinkAction(enum.Enum):
42 """Outcome of processing an OSC 8 hyperlink unit."""
44 NO_CLOSE = enum.auto() # open sequence without matching close
45 EMPTY = enum.auto() # hyperlink with no visible inner text
46 OUTSIDE = enum.auto() # hyperlink entirely outside the clip window
47 VISIBLE = enum.auto() # hyperlink overlaps the clip window
50class _HyperlinkResult(NamedTuple):
51 """
52 Result of processing an OSC 8 hyperlink.
54 Only the fields relevant to each action are populated.
55 """
57 action: _HyperlinkAction
58 close_end: int = 0
59 inner_width: int = 0
60 open_seq: str = ''
61 clipped_inner: str = ''
62 close_seq: str = ''
63 clipped_width: int = 0
64 hl_col_end: int = 0
67def _apply_sgr_wrap(result: str, captured_style: Optional[_SGRState],
68 end_style: Optional[_SGRState] = None) -> str:
69 """
70 Apply SGR prefix/suffix around *result*.
72 If an SGR state was captured at the first visible character, prefix the result with the
73 corresponding SGR sequence, and suffix with a reset if any styles remain active at the end
74 of the clipped region.
76 *end_style* is the style in effect after the final SGR sequence emitted within the clip window,
77 or ``None`` when no such sequence was emitted, the style at the first visible character is still
78 in effect. This matches :func:`wcwidth.propagate_sgr`, which decides the trailing reset by SGR
79 state at the end of each line.
80 """
81 if captured_style is not None:
82 if prefix := _sgr_state_to_sequence(captured_style):
83 result = prefix + result
84 if _sgr_state_is_active(captured_style if end_style is None else end_style):
85 result += '\x1b[0m'
86 return result
89def _process_hyperlink(
90 text: str,
91 start: int,
92 end: int,
93 fillchar: str,
94 tabsize: int,
95 ambiguous_width: int,
96 term_program: bool | str,
97 control_codes: Literal['parse', 'strict', 'ignore'],
98 *,
99 params: HyperlinkParams,
100 match_end: int,
101 col: int,
102) -> _HyperlinkResult:
103 """
104 Process an OSC 8 hyperlink unit.
106 Finds the matching close sequence, measures the inner text width, and determines whether the
107 hyperlink is empty, outside the clip window, or visible (requiring inner-text clipping).
108 """
109 # pylint: disable=too-many-locals,too-many-positional-arguments,too-many-arguments
110 close_start, close_end = Hyperlink.find_close(text, match_end)
111 if (close_start, close_end) == (-1, -1):
112 return _HyperlinkResult(_HyperlinkAction.NO_CLOSE)
113 inner_text = text[match_end:close_start]
114 inner_width = width(
115 inner_text, control_codes=control_codes,
116 tabsize=tabsize, ambiguous_width=ambiguous_width,
117 term_program=term_program,
118 )
120 if inner_width == 0:
121 return _HyperlinkResult(_HyperlinkAction.EMPTY, close_end=close_end)
123 hl_col_end = col + inner_width
125 if hl_col_end <= start or col >= end:
126 return _HyperlinkResult(_HyperlinkAction.OUTSIDE, close_end=close_end,
127 inner_width=inner_width)
129 inner_clip_start = max(0, start - col)
130 inner_clip_end = end - col
132 clipped_inner = clip(
133 inner_text, inner_clip_start, inner_clip_end,
134 fillchar=fillchar, tabsize=tabsize,
135 ambiguous_width=ambiguous_width,
136 term_program=term_program,
137 propagate_sgr=False,
138 control_codes=control_codes,
139 )
141 clipped_width = width(
142 clipped_inner, control_codes=control_codes,
143 tabsize=tabsize, ambiguous_width=ambiguous_width,
144 term_program=term_program,
145 )
147 return _HyperlinkResult(
148 _HyperlinkAction.VISIBLE,
149 close_end=close_end,
150 inner_width=inner_width,
151 open_seq=params.make_open(),
152 clipped_inner=clipped_inner,
153 close_seq=params.make_close(),
154 clipped_width=clipped_width,
155 hl_col_end=hl_col_end,
156 )
159def _reconstruct_painter(
160 cells: dict[int, tuple[str, int]],
161 sequences: list[tuple[int, int, str]],
162 start: int,
163 end: int,
164 fillchar: str,
165) -> str:
166 """
167 Reconstruct the output string from painter's algorithm state.
169 Walks columns left-to-right, interleaving escape sequences and cell content, filling gaps with
170 *fillchar*.
171 """
172 # pylint: disable=too-many-locals
173 # Group and sort sequences by column, preserving insertion order within each.
174 seqs_by_col: dict[int, list[tuple[int, str]]] = {}
175 for col_pos, order, seq_text in sequences:
176 seqs_by_col.setdefault(col_pos, []).append((order, seq_text))
177 for entries in seqs_by_col.values():
178 entries.sort()
180 max_cell_col = max(cells.keys()) if cells else -1
181 max_seq_col = max(seqs_by_col.keys()) if seqs_by_col else -1
182 max_col = max(max_cell_col, max_seq_col)
184 parts: list[str] = []
185 walk_col = 0
186 col_limit = min(max_col, end)
187 while walk_col <= col_limit:
188 # Emit any sequences anchored at this column.
189 for _, seq_text in seqs_by_col.get(walk_col, ()):
190 parts.append(seq_text)
192 if walk_col >= end:
193 walk_col += 1
194 continue
196 if walk_col in cells:
197 cell_text, cell_w = cells[walk_col]
198 parts.append(cell_text)
199 walk_col += cell_w or 1
200 else:
201 if start <= walk_col <= max_cell_col:
202 parts.append(fillchar)
203 walk_col += 1
205 # Emit sequences anchored beyond the visible region.
206 for c in sorted(seqs_by_col.keys()):
207 if c > col_limit:
208 for _, seq_text in seqs_by_col[c]:
209 parts.append(seq_text)
211 return ''.join(parts)
214def _clip_simple(
215 text: str,
216 start: int,
217 end: int,
218 *,
219 propagate_sgr: bool,
220 ambiguous_width: int,
221 term_program: bool | str,
222 fillchar: str,
223 tabsize: int,
224 control_codes: Literal['parse', 'strict', 'ignore'],
225) -> tuple[str, Optional[_SGRState], Optional[_SGRState]]:
226 """
227 Clip text without cursor movement (simple append-to-output path).
229 Returns ``(result, captured_style, end_style)``. The caller applies SGR wrapping.
230 """
231 # pylint: disable=too-complex,too-many-locals,too-many-branches,too-many-statements
232 # pylint: disable=too-many-nested-blocks
233 # code length and complexity traded for performance, to allow this to be used as a "hot path"
235 strict = control_codes == 'strict'
237 output: list[str] = []
238 col = 0
239 idx = 0
240 captured_style: Optional[_SGRState] = None
241 end_style: Optional[_SGRState] = None
242 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None
244 while idx < len(text):
245 char = text[idx]
247 # Early exit: past visible region.
248 if col >= end and char not in '\r\x08\t\x1b':
249 if captured_style is not None:
250 break
251 # propagate_sgr is always False here: with propagate_sgr=True,
252 # captured_style is set on the first visible emission in the
253 # clip window and we would have broken above. The skip-ahead
254 # optimization is only needed (and safe) when SGR tracking is off.
255 next_esc = text.find('\x1b', idx + 1)
256 if next_esc == -1:
257 break
258 idx = next_esc
259 continue
261 if char == '\x1b':
262 m = _SEQUENCE_CLASSIFY.match(text, idx)
263 if not m:
264 output.append(char)
265 idx += 1
266 continue
268 # SGR: update current_style. Sequences before the first visible
269 # emission are folded into the prefix synthesized by
270 # _apply_sgr_wrap(); those inside the clip window are emitted at
271 # their original position; those at or beyond *end* are dropped.
272 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None:
273 current_style = _sgr_state_update(current_style, m.group())
274 if captured_style is not None and col < end:
275 output.append(m.group())
276 end_style = current_style
277 idx = m.end()
278 continue
280 # OSC 8 hyperlink.
281 if hl_state := HyperlinkParams.parse(m.group()):
282 r = _process_hyperlink(
283 text, start, end, fillchar, tabsize, ambiguous_width,
284 term_program,
285 control_codes,
286 params=hl_state, match_end=m.end(), col=col,
287 )
288 if r.action is _HyperlinkAction.NO_CLOSE:
289 output.append(m.group())
290 idx = m.end()
291 elif r.action is _HyperlinkAction.EMPTY:
292 idx = r.close_end
293 elif r.action is _HyperlinkAction.OUTSIDE:
294 col += r.inner_width
295 idx = r.close_end
296 else:
297 output.append(r.open_seq)
298 output.append(r.clipped_inner)
299 output.append(r.close_seq)
300 if propagate_sgr and captured_style is None:
301 captured_style = current_style
302 # Inner text is clipped with propagate_sgr=False, so any SGR
303 # sequences it emits are verbatim: fold them into our state.
304 if propagate_sgr and current_style is not None:
305 for sgr_m in _SGR_PATTERN.finditer(r.clipped_inner):
306 current_style = _sgr_state_update(current_style, sgr_m.group())
307 end_style = current_style
308 col += r.inner_width
309 idx = r.close_end
310 continue
312 # OSC 66 Text Sizing.
313 if (ts_meta := m.group('ts_meta')) is not None:
314 ts_text = m.group('ts_text') or ''
315 ts_term = m.group('ts_term')
316 assert ts_term is not None
317 ts = TextSizing(
318 TextSizingParams.from_params(ts_meta, control_codes=control_codes),
319 ts_text, ts_term)
320 ts_width = ts.display_width(ambiguous_width)
322 if col >= start and col + ts_width <= end:
323 output.append(ts.make_sequence())
324 if propagate_sgr and captured_style is None:
325 captured_style = current_style
326 col += ts_width
327 elif col < end and col + ts_width > start:
328 ts_parts: list[str] = []
330 def _ts_write(s: str, _w: int, _col: int) -> None:
331 ts_parts.append(s)
332 col = _text_sizing_clip(
333 ts, col, start, end, fillchar, ambiguous_width,
334 term_program,
335 _ts_write)
336 output.extend(ts_parts)
337 if propagate_sgr and captured_style is None:
338 captured_style = current_style
339 else:
340 col += ts_width
341 idx = m.end()
342 continue
344 # Indeterminate-effect sequences: raise in strict mode.
345 seq = m.group()
346 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):
347 raise ValueError(
348 f"Indeterminate cursor sequence at position {idx}, "
349 f"{seq!r}"
350 )
352 # Any other recognized sequence: preserve as-is.
353 output.append(seq)
354 idx = m.end()
355 continue
357 if char == '\t':
358 # Expand tab, filling clip window with spaces.
359 if tabsize > 0:
360 next_tab = col + (tabsize - (col % tabsize))
361 while col < next_tab:
362 if start <= col < end:
363 output.append(' ')
364 if propagate_sgr and captured_style is None:
365 captured_style = current_style
366 col += 1
367 elif start <= col < end:
368 output.append('\t')
369 idx += 1
370 continue
372 grapheme = next(iter_graphemes(text, start=idx))
373 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width,
374 term_program=term_program)
376 # Emit grapheme or fillchar depending on visibility within clip window.
377 if grapheme_w == 0:
378 if start <= col < end:
379 output.append(grapheme)
380 elif col >= start and col + grapheme_w <= end:
381 output.append(grapheme)
382 if propagate_sgr and captured_style is None:
383 captured_style = current_style
384 elif col < end and col + grapheme_w > start:
385 output.append(fillchar * (min(end, col + grapheme_w) - max(start, col)))
386 if propagate_sgr and captured_style is None:
387 captured_style = current_style
389 col += grapheme_w
390 idx += len(grapheme)
392 return ''.join(output), captured_style, end_style
395def _text_sizing_clip(
396 ts: TextSizing,
397 col: int,
398 start: int,
399 end: int,
400 fillchar: str,
401 ambiguous_width: int,
402 term_program: bool | str,
403 write_cells: Callable[[str, int, int], None],
404) -> int:
405 """
406 Emit tokens for a text-sizing (OSC 66) sequence, clipped to (start, end).
408 Calls *write_cells(text, width, col)* for each emitted cell or sequence. Returns new column
409 position.
410 """
411 # pylint: disable=too-many-locals,too-many-branches,too-many-positional-arguments,too-complex
412 ts_width = ts.display_width(ambiguous_width)
414 # Fully visible: emit entire sequence
415 if col >= start and col + ts_width <= end:
416 write_cells(ts.make_sequence(), ts_width, col)
417 return col + ts_width
418 # Fully outside: just advance column
419 if col >= end or col + ts_width <= start:
420 return col + ts_width
422 # Partial overlap: decompose
423 rel_start = max(0, start - col)
424 rel_end = min(end, col + ts_width) - col
425 scale = ts.params.scale
427 units: list[tuple[str, int]] = []
428 if ts.params.width > 0:
429 for g in islice(iter_graphemes(ts.text), ts.params.width):
430 units.append((g, scale))
431 for _ in range(ts.params.width - len(units)):
432 units.append(('', scale))
433 else:
434 for g in iter_graphemes(ts.text):
435 units.append(
436 (g, width(g, ambiguous_width=ambiguous_width,
437 term_program=term_program) * scale))
439 pending_units: list[tuple[str, int]] = []
441 def flush(flush_col: int) -> None:
442 if not pending_units:
443 return
444 texts = [u[0] for u in pending_units]
445 total_w = sum(u[1] for u in pending_units)
446 params = TextSizingParams(
447 scale,
448 len(texts) if ts.params.width > 0 else 0,
449 ts.params.numerator, ts.params.denominator,
450 ts.params.vertical_align, ts.params.horizontal_align)
451 write_cells(
452 TextSizing(params, ''.join(texts), ts.terminator).make_sequence(),
453 total_w,
454 flush_col)
455 pending_units.clear()
457 flush_col_pos = col + rel_start
458 unit_pos = 0
459 for unit_text, unit_w in units:
460 unit_end = unit_pos + unit_w
461 if unit_end <= rel_start:
462 unit_pos = unit_end
463 continue
464 if unit_pos >= rel_end:
465 break
467 overlap = min(unit_end, rel_end) - max(unit_pos, rel_start)
468 if overlap == unit_w and unit_w > 0:
469 if not pending_units:
470 flush_col_pos = col + max(unit_pos, rel_start)
471 pending_units.append((unit_text, unit_w))
472 else:
473 flush(flush_col_pos)
474 abs_start = col + max(unit_pos, rel_start)
475 for i in range(overlap):
476 write_cells(fillchar, 1, abs_start + i)
477 unit_pos = unit_end
479 flush(flush_col_pos)
480 return col + ts_width
483def _clip_painter(
484 text: str,
485 start: int,
486 end: int,
487 *,
488 propagate_sgr: bool,
489 ambiguous_width: int,
490 term_program: bool | str,
491 fillchar: str,
492 tabsize: int,
493 control_codes: Literal['parse', 'strict', 'ignore'],
494) -> tuple[str, Optional[_SGRState], Optional[_SGRState]]:
495 """
496 Clip text, painting cells so that cursor movement can overwrite them.
498 Text with no cursor movement to resolve is clipped by _clip_simple() instead.
500 Returns ``(result, captured_style, end_style)``. The caller applies SGR wrapping.
501 """
502 # pylint: disable=too-complex,too-many-locals,too-many-branches
503 # pylint: disable=too-many-statements,too-many-nested-blocks
504 # code length and complexity traded for performance, to allow this to be used as a "hot path"
506 strict = control_codes == 'strict'
507 cells: dict[int, tuple[str, int]] = {}
508 hyperlink_cells: set[int] = set()
509 sequences: list[tuple[int, int, str]] = []
510 seq_order = 0
512 col = 0
513 idx = 0
514 # captured_style is a frozen snapshot of current_style taken at the first
515 # visible character emitted within the clip window (start, end); it stays
516 # None until that point. current_style is continuously updated by SGR
517 # sequences throughout the scan.
518 #
519 # When propagate_sgr is False, current_style (and therefore captured_style)
520 # remain None, and SGR sequences pass through as literal text.
521 captured_style: Optional[_SGRState] = None
522 # end_style is the state after the last SGR sequence emitted *within* the
523 # clip window; it decides the trailing reset. None until such a sequence
524 # is emitted, meaning captured_style is still in effect at the end.
525 end_style: Optional[_SGRState] = None
526 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None
527 # Next movement at or after the scan position, -1 when none is left.
528 next_movement: Optional[int] = None
530 def _write_cells(s: str, w: int, write_col: int,
531 is_hyperlink: bool = False) -> None:
532 """Write *w* cells of text *s* at *write_col*, handling wide-char splitting."""
533 nonlocal captured_style, seq_order
534 if w == 0:
535 # Zero-width: a cell here would be overwritten by the next write.
536 if s:
537 sequences.append((write_col, seq_order, s))
538 seq_order += 1
539 if propagate_sgr and captured_style is None:
540 captured_style = current_style
541 return
542 for offset in range(w):
543 src_col = write_col + offset
544 if src_col > 0 and cells.get(src_col - 1, ('', 0))[1] == 2:
545 cells[src_col - 1] = (fillchar, 1)
546 hyperlink_cells.discard(src_col - 1)
547 if cells.get(src_col, ('', 0))[1] == 2:
548 cells[src_col + 1] = (fillchar, 1)
549 hyperlink_cells.discard(src_col + 1)
550 cells.pop(src_col, None)
551 hyperlink_cells.discard(src_col)
552 cells[write_col] = (s, w)
553 if is_hyperlink:
554 for offset in range(w):
555 hyperlink_cells.add(write_col + offset)
556 if propagate_sgr and captured_style is None:
557 captured_style = current_style
559 while idx < len(text):
560 char = text[idx]
562 # Early exit: past visible region.
563 if col >= end and char not in '\r\x08\t\x1b':
564 # Movement right-of the window can still rewinds back into it.
565 if next_movement is None or 0 <= next_movement < idx:
566 # Any match starts with one of these, and rfind is far cheaper.
567 if max(text.rfind('\x08'), text.rfind('\r'), text.rfind('\x1b')) < idx:
568 next_movement = -1
569 else:
570 found = _HORIZONTAL_CURSOR_MOVEMENT.search(text, idx)
571 next_movement = -1 if found is None else found.end()
572 if next_movement < 0:
573 if captured_style is not None:
574 break
575 next_esc = text.find('\x1b', idx + 1)
576 if next_esc == -1:
577 break
578 idx = next_esc
579 continue
581 if char == '\x1b':
582 m = _SEQUENCE_CLASSIFY.match(text, idx)
583 if not m:
584 # Record lone ESC as a zero-width sequence at current column.
585 sequences.append((col, seq_order, char))
586 seq_order += 1
587 idx += 1
588 continue
590 # SGR: update current_style. Sequences before the first visible
591 # emission are folded into the prefix synthesized by
592 # _apply_sgr_wrap(); those inside the clip window are emitted at
593 # their original position; those at or beyond *end* are dropped.
594 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None:
595 current_style = _sgr_state_update(current_style, m.group())
596 if captured_style is not None and col < end:
597 sequences.append((col, seq_order, m.group()))
598 seq_order += 1
599 end_style = current_style
600 idx = m.end()
601 continue
603 # OSC 8 hyperlink.
604 if hl_state := HyperlinkParams.parse(m.group()):
605 r = _process_hyperlink(
606 text, start, end, fillchar, tabsize, ambiguous_width,
607 term_program,
608 control_codes,
609 params=hl_state, match_end=m.end(), col=col,
610 )
611 if r.action is _HyperlinkAction.NO_CLOSE:
612 sequences.append((col, seq_order, m.group()))
613 seq_order += 1
614 idx = m.end()
615 elif r.action is _HyperlinkAction.EMPTY:
616 idx = r.close_end
617 elif r.action is _HyperlinkAction.OUTSIDE:
618 col += r.inner_width
619 idx = r.close_end
620 else:
621 sequences.append((col, seq_order, r.open_seq))
622 seq_order += 1
623 _write_cells(r.clipped_inner, r.clipped_width, col,
624 is_hyperlink=True)
625 col += r.clipped_width
626 sequences.append((col, seq_order, r.close_seq))
627 seq_order += 1
628 # Inner text is clipped with propagate_sgr=False, so any SGR
629 # sequences it emits are verbatim: fold them into our state.
630 if propagate_sgr and current_style is not None:
631 for sgr_m in _SGR_PATTERN.finditer(r.clipped_inner):
632 current_style = _sgr_state_update(current_style, sgr_m.group())
633 end_style = current_style
634 col = r.hl_col_end
635 idx = r.close_end
636 continue
638 # OSC 66 Text Sizing.
639 if (ts_meta := m.group('ts_meta')) is not None:
640 ts_text = m.group('ts_text') or ''
641 ts_term = m.group('ts_term')
642 assert ts_term is not None
643 ts = TextSizing(
644 TextSizingParams.from_params(ts_meta, control_codes=control_codes),
645 ts_text, ts_term)
646 col = _text_sizing_clip(
647 ts, col, start, end, fillchar, ambiguous_width,
648 term_program,
649 _write_cells)
650 idx = m.end()
651 continue
653 # Indeterminate-effect sequences: raise in strict mode.
654 seq = m.group()
655 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):
656 raise ValueError(
657 f"Indeterminate cursor sequence at position {idx}, "
658 f"{seq!r}"
659 )
661 # Horizontal Position Absolute (CSI n G).
662 if (hpa_n := m.group('hpa_n')) is not None:
663 col = int(hpa_n) - 1 if hpa_n else 0
664 idx = m.end()
665 continue
667 # Cursor Forward (CSI n C).
668 if (cforward_n := m.group('cforward_n')) is not None:
669 n_forward = int(cforward_n) if cforward_n else 1
670 move_end = col + n_forward
671 if col < end and move_end > start:
672 for i in range(max(col, start), min(move_end, end)):
673 _write_cells(fillchar, 1, i)
674 col = move_end
675 idx = m.end()
676 continue
678 # Cursor Backward (CSI n D).
679 if (cbackward_n := m.group('cbackward_n')) is not None:
680 n_backward = int(cbackward_n) if cbackward_n else 1
681 if strict and n_backward > col:
682 raise ValueError(
683 f"Cursor left movement at position {idx} would move "
684 f"{n_backward} cells left from column {col}, "
685 f"exceeding string start"
686 )
687 col -= n_backward
688 if col < 0:
689 col = 0
690 idx = m.end()
691 continue
693 # Any other recognized sequence: preserve as-is.
694 sequences.append((col, seq_order, m.group()))
695 seq_order += 1
696 idx = m.end()
697 continue
699 # Carriage return.
700 if char == '\r':
701 col = 0
702 idx += 1
703 continue
705 # Backspace.
706 if char == '\x08':
707 if col > 0:
708 col -= 1
709 idx += 1
710 continue
712 # Tab expansion.
713 if char == '\t':
714 if tabsize > 0:
715 next_tab = col + (tabsize - (col % tabsize))
716 for fill_col in range(max(col, start), min(next_tab, end)):
717 _write_cells(' ', 1, fill_col)
718 col = next_tab
719 elif start <= col < end:
720 sequences.append((col, seq_order, '\t'))
721 seq_order += 1
722 idx += 1
723 continue
725 # Grapheme cluster.
726 grapheme = next(iter_graphemes(text, start=idx))
727 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width,
728 term_program=term_program)
730 # Emit grapheme or fillchar depending on visibility within clip window.
731 if grapheme_w == 0:
732 if start <= col < end:
733 sequences.append((col, seq_order, grapheme))
734 seq_order += 1
735 elif col >= start and col + grapheme_w <= end:
736 _write_cells(grapheme, grapheme_w, col)
737 elif col < end and col + grapheme_w > start:
738 clip_start = max(start, col)
739 for offset in range(min(end, col + grapheme_w) - clip_start):
740 _write_cells(fillchar, 1, clip_start + offset)
742 col += grapheme_w
743 idx += len(grapheme)
745 return (_reconstruct_painter(cells, sequences, start, end, fillchar),
746 captured_style, end_style)
749def clip(
750 text: str,
751 start: int = 0,
752 end: int = -1,
753 *,
754 fillchar: str = ' ',
755 tabsize: int = 8,
756 ambiguous_width: int = 1,
757 propagate_sgr: bool = True,
758 control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
759 overtyping: Optional[bool] = None,
760 term_program: bool | str = False,
761) -> str:
762 r"""
763 Clip text to display columns (start, end) while preserving all terminal sequences.
765 This function extracts a substring using visible column positions. Terminal escape sequences
766 are preserved in output. If a wide character (width of 2) is
767 split at a boundary, it is replaced with ``fillchar``.
769 TAB characters (``\t``) are expanded to spaces up to the next tab stop, controlled by the
770 ``tabsize`` parameter. When cursor movement is detected, a "painter's algorithm" is used unless
771 ``overtyping=False`` is set. Cursor movement control codes are parsed for their effects instead
772 of ignored. For these operations, it is assumed that ``text`` begins at column 0.
774 For text containing **OSC 8 hyperlinks**, the visible text inside a hyperlink is clipped to the
775 requested column range and the hyperlink sequence is rebuilt::
777 >>> clip('\x1b]8;;http://example.com\x07Click This link\x1b]8;;\x07', 6, 10)
778 '\x1b]8;;http://example.com\x07This\x1b]8;;\x07'
780 :param text: String to clip, may contain terminal escape sequences.
781 :param start: Absolute starting column (inclusive, 0-indexed), default ``0``.
782 :param end: Absolute ending column (exclusive). The default value, ``-1``,
783 signifies "to the end of the line", clipping only from *start* without
784 requiring the caller to measure the display width of *text*.
785 :param fillchar: Character to use when a wide character must be split at
786 a boundary (default space). Must have display width of 1.
787 :param tabsize: Tab stop width (default 8). Set to 0 to pass tabs through
788 as zero-width (preserved in output but don't advance column position).
789 :param ambiguous_width: Width to use for East Asian Ambiguous (A)
790 characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
791 :param propagate_sgr: If True (default), SGR (terminal styling) sequences
792 are propagated, matching :func:`propagate_sgr`. The result begins with
793 any style active at the start position, retains any style changes
794 occurring within the clipped region at their original position, and
795 ends with a reset sequence if styles are active at the end position.
796 :param control_codes: How to handle control characters and sequences:
798 - ``'parse'`` (default): Track horizontal cursor movement and clip
799 hyperlink text. Cursor overwrite is always allowed, with best effort
800 results; indeterminate sequences (home, clear, reset, etc.) are
801 preserved as zero-width.
802 - ``'strict'``: Like ``parse``, but raises :exc:`ValueError` on
803 sequences with indeterminate effects (cursor home, clear screen,
804 reset, vertical movement, etc.) matching :func:`width` behavior.
805 Also raises on out-of-bounds horizontal cursor movement.
806 - ``'ignore'``: All control characters are treated as zero-width.
807 Cursor movement is not tracked (fastest path).
809 :param overtyping: Whether to use the painter's algorithm for cursor
810 movement (``\b`` backspace, ``\r`` carriage return, and CSI cursor
811 left/right/position sequences). When ``None`` (default), auto-detects
812 by scanning for these characters in *text*. Set to ``False`` for improved
813 performance when the caller knows *text* contains no cursor movement
814 characters. Set to ``True`` to force the painter's algorithm (useful
815 for testing). Has no effect when ``control_codes='ignore'``.
816 :param term_program: Terminal software identifier for table correction.
817 ``False`` (default) disables override lookup. ``True`` reads the
818 ``TERM_PROGRAM`` or ``TERM`` environment variable for auto-detection.
819 Accepts a canonical terminal name matching :func:`list_term_programs`,
820 such as from XTVERSION_, ENQ_, or ``TERM_PROGRAM``.
822 .. versionadded:: 0.8.0
824 :returns: Substring of ``text`` spanning display columns (start, end),
825 with all terminal sequences preserved and wide characters at boundaries
826 replaced with ``fillchar``.
828 :raises ValueError: If ``end`` is negative and not ``-1``, or if
829 ``control_codes='strict'`` and an indeterminate-effect sequence or
830 out-of-bounds cursor movement is encountered.
832 SGR (terminal styling) sequences are propagated by default. The result
833 begins with any active style and ends with a reset::
835 >>> clip('\x1b[1;34mHello world\x1b[0m', 6, 11)
836 '\x1b[1;34mworld\x1b[0m'
837 >>> clip('\x1b[1mbold\x1b[m normal', 1, 9)
838 '\x1b[1mold\x1b[m norm'
840 Set ``propagate_sgr=False`` to disable this behavior.
842 .. note::
844 Segmentation follows UAX #29 grapheme clusters. Tamil, Kannada and Sinhala may be clipped
845 short of ``end`` and leave a bare virama at the boundary. Correct rendering of these
846 languages is not specified by unicode.org standards, and no terminal emulator or editor
847 handles them legibly, so this library function makes no attempt at accommodating them.
849 .. versionadded:: 0.3.0
851 .. versionchanged:: 0.5.0
852 Added ``propagate_sgr`` parameter (default True).
854 .. versionchanged:: 0.7.0
855 Added ``control_codes`` parameter (default 'parse').
856 OSC 8 hyperlink-aware clipping. OSC 66 text sizing protocol support.
857 Added ``overtyping`` parameter (default None, auto-detect).
859 .. versionchanged:: 0.8.4
860 ``start`` now defaults to ``0`` and ``end`` to ``-1``, meaning "to the
861 end of the line"::
863 >>> clip('\x1b[1;34mHello world\x1b[0m', 6)
864 '\x1b[1;34mworld\x1b[0m'
866 Example::
868 >>> clip('hello world', 0, 5)
869 'hello'
870 >>> clip('中文字', 0, 3) # Wide char split at column 3
871 '中 '
872 >>> clip('a\tb', 0, 10) # Tab expanded to spaces
873 'a b'
874 """
875 start = max(start, 0)
876 if end < 0:
877 if end != -1:
878 raise ValueError(
879 f"end must be -1 (to end of line) or non-negative, got {end}")
880 # Unbounded: clip only from *start*, to the end of the line.
881 end = sys.maxsize
882 if end <= start:
883 return ''
885 # Fast path: printable ASCII only.
886 if text.isascii() and text.isprintable():
887 return text[start:end]
889 # Offload to libwcwidth, which reports an unsupported sequence rather than
890 # answering differently. Only the options are tested here, never the text.
891 if (_c_clip is not None
892 and control_codes == 'parse'
893 and overtyping is not True
894 and tabsize > 0
895 and len(fillchar) == 1 and fillchar.isascii()):
896 result = _c_clip(
897 text, start, end, fillchar=fillchar, tabsize=tabsize,
898 ambiguous_width=ambiguous_width, propagate_sgr=propagate_sgr,
899 control_codes=control_codes, term_program=term_program)
900 if result is not None:
901 return result
903 ambiguous_width = _clamp_ambiguous_width(ambiguous_width)
905 # No escape sequences => no SGR tracking needed.
906 has_esc = '\x1b' in text
907 if propagate_sgr and not has_esc:
908 propagate_sgr = False
910 # Determine whether painter's algorithm is needed.
911 if overtyping is None:
912 # Auto-detect: scan for cursor movement characters.
913 overtyping = (
914 control_codes != 'ignore' and
915 ('\x08' in text or '\r' in text or
916 (has_esc and bool(_HORIZONTAL_CURSOR_MOVEMENT.search(text))))
917 )
918 elif overtyping and control_codes == 'ignore':
919 overtyping = False # control_codes='ignore' overrides
921 if overtyping:
922 return _apply_sgr_wrap(*_clip_painter(
923 text=text,
924 start=start,
925 end=end,
926 propagate_sgr=propagate_sgr,
927 ambiguous_width=ambiguous_width,
928 term_program=term_program,
929 fillchar=fillchar,
930 tabsize=tabsize,
931 control_codes=control_codes,
932 ))
934 return _apply_sgr_wrap(*_clip_simple(
935 text=text,
936 start=start,
937 end=end,
938 propagate_sgr=propagate_sgr,
939 ambiguous_width=ambiguous_width,
940 term_program=term_program,
941 fillchar=fillchar,
942 tabsize=tabsize,
943 control_codes=control_codes,
944 ))