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