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

435 statements  

1"""This is a python implementation of clip().""" 

2from __future__ import annotations 

3 

4# std imports 

5import os 

6import sys 

7import enum 

8from itertools import islice 

9 

10from typing import Literal, Callable, Optional, NamedTuple 

11 

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) 

27 

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 

39 

40 

41class _HyperlinkAction(enum.Enum): 

42 """Outcome of processing an OSC 8 hyperlink unit.""" 

43 

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 

48 

49 

50class _HyperlinkResult(NamedTuple): 

51 """ 

52 Result of processing an OSC 8 hyperlink. 

53 

54 Only the fields relevant to each action are populated. 

55 """ 

56 

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 

65 

66 

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*. 

71 

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. 

75 

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 

87 

88 

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. 

105 

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 ) 

119 

120 if inner_width == 0: 

121 return _HyperlinkResult(_HyperlinkAction.EMPTY, close_end=close_end) 

122 

123 hl_col_end = col + inner_width 

124 

125 if hl_col_end <= start or col >= end: 

126 return _HyperlinkResult(_HyperlinkAction.OUTSIDE, close_end=close_end, 

127 inner_width=inner_width) 

128 

129 inner_clip_start = max(0, start - col) 

130 inner_clip_end = end - col 

131 

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 ) 

140 

141 clipped_width = width( 

142 clipped_inner, control_codes=control_codes, 

143 tabsize=tabsize, ambiguous_width=ambiguous_width, 

144 term_program=term_program, 

145 ) 

146 

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 ) 

157 

158 

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. 

168 

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() 

179 

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) 

183 

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) 

191 

192 if walk_col >= end: 

193 walk_col += 1 

194 continue 

195 

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 

204 

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) 

210 

211 return ''.join(parts) 

212 

213 

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). 

228 

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" 

234 

235 strict = control_codes == 'strict' 

236 

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 

243 

244 while idx < len(text): 

245 char = text[idx] 

246 

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 

260 

261 if char == '\x1b': 

262 m = _SEQUENCE_CLASSIFY.match(text, idx) 

263 if not m: 

264 output.append(char) 

265 idx += 1 

266 continue 

267 

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 

279 

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 

311 

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) 

321 

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] = [] 

329 

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 

343 

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 ) 

351 

352 # Any other recognized sequence: preserve as-is. 

353 output.append(seq) 

354 idx = m.end() 

355 continue 

356 

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 

371 

372 grapheme = next(iter_graphemes(text, start=idx)) 

373 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width, 

374 term_program=term_program) 

375 

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 

388 

389 col += grapheme_w 

390 idx += len(grapheme) 

391 

392 return ''.join(output), captured_style, end_style 

393 

394 

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). 

407 

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) 

413 

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 

421 

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 

426 

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)) 

438 

439 pending_units: list[tuple[str, int]] = [] 

440 

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() 

456 

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 

466 

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 

478 

479 flush(flush_col_pos) 

480 return col + ts_width 

481 

482 

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. 

497 

498 Text with no cursor movement to resolve is clipped by _clip_simple() instead. 

499 

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" 

505 

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 

511 

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 

529 

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 

558 

559 while idx < len(text): 

560 char = text[idx] 

561 

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 

580 

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 

589 

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 

602 

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 

637 

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 

652 

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 ) 

660 

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 

666 

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 

677 

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 

692 

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 

698 

699 # Carriage return. 

700 if char == '\r': 

701 col = 0 

702 idx += 1 

703 continue 

704 

705 # Backspace. 

706 if char == '\x08': 

707 if col > 0: 

708 col -= 1 

709 idx += 1 

710 continue 

711 

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 

724 

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) 

729 

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) 

741 

742 col += grapheme_w 

743 idx += len(grapheme) 

744 

745 return (_reconstruct_painter(cells, sequences, start, end, fillchar), 

746 captured_style, end_style) 

747 

748 

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. 

764 

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``. 

768 

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. 

773 

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:: 

776 

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' 

779 

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: 

797 

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). 

808 

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``. 

821 

822 .. versionadded:: 0.8.0 

823 

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``. 

827 

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. 

831 

832 SGR (terminal styling) sequences are propagated by default. The result 

833 begins with any active style and ends with a reset:: 

834 

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' 

839 

840 Set ``propagate_sgr=False`` to disable this behavior. 

841 

842 .. note:: 

843 

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. 

848 

849 .. versionadded:: 0.3.0 

850 

851 .. versionchanged:: 0.5.0 

852 Added ``propagate_sgr`` parameter (default True). 

853 

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). 

858 

859 .. versionchanged:: 0.8.4 

860 ``start`` now defaults to ``0`` and ``end`` to ``-1``, meaning "to the 

861 end of the line":: 

862 

863 >>> clip('\x1b[1;34mHello world\x1b[0m', 6) 

864 '\x1b[1;34mworld\x1b[0m' 

865 

866 Example:: 

867 

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 '' 

884 

885 # Fast path: printable ASCII only. 

886 if text.isascii() and text.isprintable(): 

887 return text[start:end] 

888 

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 

902 

903 ambiguous_width = _clamp_ambiguous_width(ambiguous_width) 

904 

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 

909 

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 

920 

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 )) 

933 

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 ))