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

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

26 

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 

43 

44 

45class _HyperlinkAction(enum.Enum): 

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

47 

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 

52 

53 

54class _HyperlinkResult(NamedTuple): 

55 """ 

56 Result of processing an OSC 8 hyperlink. 

57 

58 Only the fields relevant to each action are populated. 

59 """ 

60 

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 

69 

70 

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

75 

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. 

79 

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 

91 

92 

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. 

109 

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 ) 

123 

124 if inner_width == 0: 

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

126 

127 hl_col_end = col + inner_width 

128 

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

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

131 inner_width=inner_width) 

132 

133 inner_clip_start = max(0, start - col) 

134 inner_clip_end = end - col 

135 

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 ) 

144 

145 clipped_width = width( 

146 clipped_inner, control_codes=control_codes, 

147 tabsize=tabsize, ambiguous_width=ambiguous_width, 

148 term_program=term_program, 

149 ) 

150 

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 ) 

161 

162 

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. 

172 

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

183 

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) 

187 

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) 

195 

196 if walk_col >= end: 

197 walk_col += 1 

198 continue 

199 

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 

208 

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) 

214 

215 return ''.join(parts) 

216 

217 

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

232 

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" 

238 

239 strict = control_codes == 'strict' 

240 

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 

247 

248 while idx < len(text): 

249 char = text[idx] 

250 

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 

264 

265 if char == '\x1b': 

266 m = _SEQUENCE_CLASSIFY.match(text, idx) 

267 if not m: 

268 output.append(char) 

269 idx += 1 

270 continue 

271 

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 

283 

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 

315 

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) 

325 

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

333 

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 

347 

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 ) 

355 

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

357 output.append(seq) 

358 idx = m.end() 

359 continue 

360 

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 

375 

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

377 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width, 

378 term_program=term_program) 

379 

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 

392 

393 col += grapheme_w 

394 idx += len(grapheme) 

395 

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

397 

398 

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

411 

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) 

417 

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 

425 

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 

430 

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

442 

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

444 

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

460 

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 

470 

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 

482 

483 flush(flush_col_pos) 

484 return col + ts_width 

485 

486 

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. 

501 

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

503 

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" 

509 

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 

515 

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 

533 

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 

562 

563 while idx < len(text): 

564 char = text[idx] 

565 

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 

584 

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 

593 

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 

606 

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 

641 

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 

656 

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 ) 

664 

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 

670 

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 

681 

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 

696 

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 

702 

703 # Carriage return. 

704 if char == '\r': 

705 col = 0 

706 idx += 1 

707 continue 

708 

709 # Backspace. 

710 if char == '\x08': 

711 if col > 0: 

712 col -= 1 

713 idx += 1 

714 continue 

715 

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 

728 

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) 

733 

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) 

745 

746 col += grapheme_w 

747 idx += len(grapheme) 

748 

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

750 captured_style, end_style) 

751 

752 

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. 

768 

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

772 

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. 

777 

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

780 

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' 

783 

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: 

801 

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

812 

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

825 

826 .. versionadded:: 0.8.0 

827 

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

831 

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. 

835 

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

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

838 

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' 

843 

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

845 

846 .. note:: 

847 

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. 

852 

853 .. versionadded:: 0.3.0 

854 

855 .. versionchanged:: 0.5.0 

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

857 

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

862 

863 .. versionchanged:: 0.8.4 

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

865 end of the line":: 

866 

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

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

869 

870 Example:: 

871 

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

888 

889 # Fast path: printable ASCII only. 

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

891 return text[start:end] 

892 

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 

906 

907 ambiguous_width = _clamp_ambiguous_width(ambiguous_width) 

908 

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 

913 

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 

924 

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

937 

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