Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wcwidth/_clip.py: 8%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

391 statements  

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

2from __future__ import annotations 

3 

4# std imports 

5import enum 

6from itertools import islice 

7 

8from typing import Literal, Callable, Optional, NamedTuple 

9 

10# local 

11from ._width import width 

12from .grapheme import iter_graphemes 

13from .hyperlink import Hyperlink, HyperlinkParams 

14from .sgr_state import (_SGR_PATTERN, 

15 _SGR_STATE_DEFAULT, 

16 _SGRState, 

17 _sgr_state_update, 

18 _sgr_state_is_active, 

19 _sgr_state_to_sequence) 

20from .text_sizing import TextSizing, TextSizingParams 

21from .escape_sequences import (_SEQUENCE_CLASSIFY, 

22 _HORIZONTAL_CURSOR_MOVEMENT, 

23 INDETERMINATE_EFFECT_SEQUENCE) 

24 

25 

26class _HyperlinkAction(enum.Enum): 

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

28 

29 NO_CLOSE = enum.auto() # open sequence without matching close 

30 EMPTY = enum.auto() # hyperlink with no visible inner text 

31 OUTSIDE = enum.auto() # hyperlink entirely outside the clip window 

32 VISIBLE = enum.auto() # hyperlink overlaps the clip window 

33 

34 

35class _HyperlinkResult(NamedTuple): 

36 """ 

37 Result of processing an OSC 8 hyperlink. 

38 

39 Only the fields relevant to each action are populated. 

40 """ 

41 

42 action: _HyperlinkAction 

43 close_end: int = 0 

44 inner_width: int = 0 

45 open_seq: str = '' 

46 clipped_inner: str = '' 

47 close_seq: str = '' 

48 clipped_width: int = 0 

49 hl_col_end: int = 0 

50 

51 

52def _apply_sgr_wrap(result: str, captured_style: Optional[_SGRState], 

53 end_style: Optional[_SGRState] = None) -> str: 

54 """ 

55 Apply SGR prefix/suffix around *result*. 

56 

57 If an SGR state was captured at the first visible character, prefix the result with the 

58 corresponding SGR sequence, and suffix with a reset if any styles remain active at the end 

59 of the clipped region. 

60 

61 *end_style* is the style in effect after the final SGR sequence emitted within the clip window, 

62 or ``None`` when no such sequence was emitted, the style at the first visible character is still 

63 in effect. This matches :func:`wcwidth.propagate_sgr`, which decides the trailing reset by SGR 

64 state at the end of each line. 

65 """ 

66 if captured_style is not None: 

67 if prefix := _sgr_state_to_sequence(captured_style): 

68 result = prefix + result 

69 if _sgr_state_is_active(captured_style if end_style is None else end_style): 

70 result += '\x1b[0m' 

71 return result 

72 

73 

74def _process_hyperlink( 

75 text: str, 

76 start: int, 

77 end: int, 

78 fillchar: str, 

79 tabsize: int, 

80 ambiguous_width: int, 

81 term_program: bool | str, 

82 control_codes: Literal['parse', 'strict', 'ignore'], 

83 *, 

84 params: HyperlinkParams, 

85 match_end: int, 

86 col: int, 

87) -> _HyperlinkResult: 

88 """ 

89 Process an OSC 8 hyperlink unit. 

90 

91 Finds the matching close sequence, measures the inner text width, and determines whether the 

92 hyperlink is empty, outside the clip window, or visible (requiring inner-text clipping). 

93 """ 

94 # pylint: disable=too-many-locals,too-many-positional-arguments,too-many-arguments 

95 close_start, close_end = Hyperlink.find_close(text, match_end) 

96 if (close_start, close_end) == (-1, -1): 

97 return _HyperlinkResult(_HyperlinkAction.NO_CLOSE) 

98 inner_text = text[match_end:close_start] 

99 inner_width = width( 

100 inner_text, control_codes=control_codes, 

101 tabsize=tabsize, ambiguous_width=ambiguous_width, 

102 term_program=term_program, 

103 ) 

104 

105 if inner_width == 0: 

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

107 

108 hl_col_end = col + inner_width 

109 

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

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

112 inner_width=inner_width) 

113 

114 inner_clip_start = max(0, start - col) 

115 inner_clip_end = end - col 

116 

117 clipped_inner = clip( 

118 inner_text, inner_clip_start, inner_clip_end, 

119 fillchar=fillchar, tabsize=tabsize, 

120 ambiguous_width=ambiguous_width, 

121 term_program=term_program, 

122 propagate_sgr=False, 

123 control_codes=control_codes, 

124 ) 

125 

126 clipped_width = width( 

127 clipped_inner, control_codes=control_codes, 

128 tabsize=tabsize, ambiguous_width=ambiguous_width, 

129 term_program=term_program, 

130 ) 

131 

132 return _HyperlinkResult( 

133 _HyperlinkAction.VISIBLE, 

134 close_end=close_end, 

135 inner_width=inner_width, 

136 open_seq=params.make_open(), 

137 clipped_inner=clipped_inner, 

138 close_seq=params.make_close(), 

139 clipped_width=clipped_width, 

140 hl_col_end=hl_col_end, 

141 ) 

142 

143 

144def _reconstruct_painter( 

145 cells: dict[int, tuple[str, int]], 

146 sequences: list[tuple[int, int, str]], 

147 start: int, 

148 end: int, 

149 fillchar: str, 

150) -> str: 

151 """ 

152 Reconstruct the output string from painter's algorithm state. 

153 

154 Walks columns left-to-right, interleaving escape sequences and cell content, filling gaps with 

155 *fillchar*. 

156 """ 

157 # pylint: disable=too-many-locals 

158 # Group and sort sequences by column, preserving insertion order within each. 

159 seqs_by_col: dict[int, list[tuple[int, str]]] = {} 

160 for col_pos, order, seq_text in sequences: 

161 seqs_by_col.setdefault(col_pos, []).append((order, seq_text)) 

162 for entries in seqs_by_col.values(): 

163 entries.sort() 

164 

165 max_cell_col = max(cells.keys()) if cells else -1 

166 max_seq_col = max(seqs_by_col.keys()) if seqs_by_col else -1 

167 max_col = max(max_cell_col, max_seq_col) 

168 

169 parts: list[str] = [] 

170 walk_col = 0 

171 col_limit = min(max_col, end) 

172 while walk_col <= col_limit: 

173 # Emit any sequences anchored at this column. 

174 for _, seq_text in seqs_by_col.get(walk_col, ()): 

175 parts.append(seq_text) 

176 

177 if walk_col >= end: 

178 walk_col += 1 

179 continue 

180 

181 if walk_col in cells: 

182 cell_text, cell_w = cells[walk_col] 

183 parts.append(cell_text) 

184 walk_col += cell_w 

185 else: 

186 if start <= walk_col <= max_cell_col: 

187 parts.append(fillchar) 

188 walk_col += 1 

189 

190 # Emit sequences anchored beyond the visible region. 

191 for c in sorted(seqs_by_col.keys()): 

192 if c > col_limit: 

193 for _, seq_text in seqs_by_col[c]: 

194 parts.append(seq_text) 

195 

196 return ''.join(parts) 

197 

198 

199def _clip_simple( 

200 text: str, 

201 start: int, 

202 end: int, 

203 *, 

204 propagate_sgr: bool, 

205 ambiguous_width: int, 

206 term_program: bool | str, 

207 fillchar: str, 

208 tabsize: int, 

209 strict: bool, 

210 control_codes: Literal['parse', 'strict', 'ignore'], 

211) -> tuple[str, Optional[_SGRState], Optional[_SGRState]]: 

212 """ 

213 Clip text without cursor movement (simple append-to-output path). 

214 

215 Returns ``(result, captured_style, end_style)``. The caller applies SGR wrapping. 

216 """ 

217 # pylint: disable=too-complex,too-many-locals,too-many-branches,too-many-statements 

218 # pylint: disable=too-many-nested-blocks 

219 # code length and complexity traded for performance, to allow this to be used as a "hot path" 

220 

221 output: list[str] = [] 

222 col = 0 

223 idx = 0 

224 # captured_style is a frozen snapshot of current_style taken at the first 

225 # visible character emitted within the clip window (start, end). It stays 

226 # None until that point. current_style, by contrast, is continuously 

227 # updated by SGR sequences throughout the scan. The snapshot is what the 

228 # caller uses to wrap the result in the correct SGR state. 

229 # 

230 # When propagate_sgr is False, current_style (and therefore captured_style) 

231 # remain None, and SGR sequences pass through as literal text. 

232 captured_style: Optional[_SGRState] = None 

233 # end_style is the state after the last SGR sequence emitted *within* the 

234 # clip window; it decides the trailing reset. None until such a sequence 

235 # is emitted, meaning captured_style is still in effect at the end. 

236 end_style: Optional[_SGRState] = None 

237 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None 

238 

239 while idx < len(text): 

240 char = text[idx] 

241 

242 # Early exit: past visible region. 

243 if col >= end and char not in '\r\x08\t\x1b': 

244 if captured_style is not None: 

245 break 

246 # propagate_sgr is always False here: with propagate_sgr=True, 

247 # captured_style is set on the first visible emission in the 

248 # clip window and we would have broken above. The skip-ahead 

249 # optimization is only needed (and safe) when SGR tracking is off. 

250 next_esc = text.find('\x1b', idx + 1) 

251 if next_esc == -1: 

252 break 

253 idx = next_esc 

254 continue 

255 

256 if char == '\x1b': 

257 m = _SEQUENCE_CLASSIFY.match(text, idx) 

258 if not m: 

259 output.append(char) 

260 idx += 1 

261 continue 

262 

263 # SGR: update current_style. Sequences before the first visible 

264 # emission are folded into the prefix synthesized by 

265 # _apply_sgr_wrap(); those inside the clip window are emitted at 

266 # their original position; those at or beyond *end* are dropped. 

267 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None: 

268 current_style = _sgr_state_update(current_style, m.group()) 

269 if captured_style is not None and col < end: 

270 output.append(m.group()) 

271 end_style = current_style 

272 idx = m.end() 

273 continue 

274 

275 # OSC 8 hyperlink. 

276 if hl_state := HyperlinkParams.parse(m.group()): 

277 r = _process_hyperlink( 

278 text, start, end, fillchar, tabsize, ambiguous_width, 

279 term_program, 

280 control_codes, 

281 params=hl_state, match_end=m.end(), col=col, 

282 ) 

283 if r.action is _HyperlinkAction.NO_CLOSE: 

284 output.append(m.group()) 

285 idx = m.end() 

286 elif r.action is _HyperlinkAction.EMPTY: 

287 idx = r.close_end 

288 elif r.action is _HyperlinkAction.OUTSIDE: 

289 col += r.inner_width 

290 idx = r.close_end 

291 else: 

292 output.append(r.open_seq) 

293 output.append(r.clipped_inner) 

294 output.append(r.close_seq) 

295 if propagate_sgr and captured_style is None: 

296 captured_style = current_style 

297 # Inner text is clipped with propagate_sgr=False, so any SGR 

298 # sequences it emits are verbatim: fold them into our state. 

299 if propagate_sgr and current_style is not None: 

300 for sgr_m in _SGR_PATTERN.finditer(r.clipped_inner): 

301 current_style = _sgr_state_update(current_style, sgr_m.group()) 

302 end_style = current_style 

303 col += r.inner_width 

304 idx = r.close_end 

305 continue 

306 

307 # OSC 66 Text Sizing. 

308 if (ts_meta := m.group('ts_meta')) is not None: 

309 ts_text = m.group('ts_text') 

310 ts_term = m.group('ts_term') 

311 assert ts_text is not None and ts_term is not None 

312 ts = TextSizing( 

313 TextSizingParams.from_params(ts_meta, control_codes=control_codes), 

314 ts_text, ts_term) 

315 ts_width = ts.display_width(ambiguous_width) 

316 

317 if col >= start and col + ts_width <= end: 

318 output.append(ts.make_sequence()) 

319 if propagate_sgr and captured_style is None: 

320 captured_style = current_style 

321 col += ts_width 

322 elif col < end and col + ts_width > start: 

323 ts_parts: list[str] = [] 

324 

325 def _ts_write(s: str, _w: int, _col: int) -> None: 

326 ts_parts.append(s) 

327 col = _text_sizing_clip( 

328 ts, col, start, end, fillchar, ambiguous_width, 

329 term_program, 

330 _ts_write) 

331 output.extend(ts_parts) 

332 if propagate_sgr and captured_style is None: 

333 captured_style = current_style 

334 else: 

335 col += ts_width 

336 idx = m.end() 

337 continue 

338 

339 # Indeterminate-effect sequences: raise in strict mode. 

340 seq = m.group() 

341 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq): 

342 raise ValueError( 

343 f"Indeterminate cursor sequence at position {idx}, " 

344 f"{seq!r}" 

345 ) 

346 

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

348 output.append(seq) 

349 idx = m.end() 

350 continue 

351 

352 if char == '\t': 

353 # Expand tab, filling clip window with spaces. 

354 if tabsize > 0: 

355 next_tab = col + (tabsize - (col % tabsize)) 

356 while col < next_tab: 

357 if start <= col < end: 

358 output.append(' ') 

359 if propagate_sgr and captured_style is None: 

360 captured_style = current_style 

361 col += 1 

362 else: 

363 output.append('\t') 

364 idx += 1 

365 continue 

366 

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

368 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width, 

369 term_program=term_program) 

370 

371 # Emit grapheme or fillchar depending on visibility within clip window. 

372 if grapheme_w == 0: 

373 if start <= col < end: 

374 output.append(grapheme) 

375 elif col >= start and col + grapheme_w <= end: 

376 output.append(grapheme) 

377 if propagate_sgr and captured_style is None: 

378 captured_style = current_style 

379 elif col < end and col + grapheme_w > start: 

380 output.append(fillchar * (min(end, col + grapheme_w) - max(start, col))) 

381 if propagate_sgr and captured_style is None: 

382 captured_style = current_style 

383 

384 col += grapheme_w 

385 idx += len(grapheme) 

386 

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

388 

389 

390def _text_sizing_clip( 

391 ts: TextSizing, 

392 col: int, 

393 start: int, 

394 end: int, 

395 fillchar: str, 

396 ambiguous_width: int, 

397 term_program: bool | str, 

398 write_cells: Callable[[str, int, int], None], 

399) -> int: 

400 """ 

401 Emit tokens for a text-sizing (OSC 66) sequence, clipped to (start, end). 

402 

403 Calls *write_cells(text, width, col)* for each emitted cell or sequence. Returns new column 

404 position. 

405 """ 

406 # pylint: disable=too-many-locals,too-many-branches,too-many-positional-arguments,too-complex 

407 ts_width = ts.display_width(ambiguous_width) 

408 

409 # Fully visible: emit entire sequence 

410 if col >= start and col + ts_width <= end: 

411 write_cells(ts.make_sequence(), ts_width, col) 

412 return col + ts_width 

413 # Fully outside: just advance column 

414 if col >= end or col + ts_width <= start: 

415 return col + ts_width 

416 

417 # Partial overlap: decompose 

418 rel_start = max(0, start - col) 

419 rel_end = min(end, col + ts_width) - col 

420 scale = ts.params.scale 

421 

422 units: list[tuple[str, int]] = [] 

423 if ts.params.width > 0: 

424 for g in islice(iter_graphemes(ts.text), ts.params.width): 

425 units.append((g, scale)) 

426 for _ in range(ts.params.width - len(units)): 

427 units.append(('', scale)) 

428 else: 

429 for g in iter_graphemes(ts.text): 

430 units.append( 

431 (g, width(g, ambiguous_width=ambiguous_width, 

432 term_program=term_program) * scale)) 

433 

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

435 

436 def flush(flush_col: int) -> None: 

437 if not pending_units: 

438 return 

439 texts = [u[0] for u in pending_units] 

440 total_w = sum(u[1] for u in pending_units) 

441 params = TextSizingParams( 

442 scale, 

443 len(texts) if ts.params.width > 0 else 0, 

444 ts.params.numerator, ts.params.denominator, 

445 ts.params.vertical_align, ts.params.horizontal_align) 

446 write_cells( 

447 TextSizing(params, ''.join(texts), ts.terminator).make_sequence(), 

448 total_w, 

449 flush_col) 

450 pending_units.clear() 

451 

452 flush_col_pos = col + rel_start 

453 unit_pos = 0 

454 for unit_text, unit_w in units: 

455 unit_end = unit_pos + unit_w 

456 if unit_end <= rel_start: 

457 unit_pos = unit_end 

458 continue 

459 if unit_pos >= rel_end: 

460 break 

461 

462 overlap = min(unit_end, rel_end) - max(unit_pos, rel_start) 

463 if overlap == unit_w and unit_w > 0: 

464 if not pending_units: 

465 flush_col_pos = col + max(unit_pos, rel_start) 

466 pending_units.append((unit_text, unit_w)) 

467 else: 

468 flush(flush_col_pos) 

469 abs_start = col + max(unit_pos, rel_start) 

470 for i in range(overlap): 

471 write_cells(fillchar, 1, abs_start + i) 

472 unit_pos = unit_end 

473 

474 flush(flush_col_pos) 

475 return col + ts_width 

476 

477 

478def _clip_painter( 

479 text: str, 

480 start: int, 

481 end: int, 

482 *, 

483 propagate_sgr: bool, 

484 ambiguous_width: int, 

485 term_program: bool | str, 

486 fillchar: str, 

487 tabsize: int, 

488 strict: bool, 

489 control_codes: Literal['parse', 'strict', 'ignore'], 

490) -> tuple[str, Optional[_SGRState], Optional[_SGRState]]: 

491 """ 

492 Clip text with cursor movement (painter's algorithm path). 

493 

494 Returns ``(result, captured_style, end_style)``. The caller applies SGR wrapping. 

495 """ 

496 # pylint: disable=too-complex,too-many-locals,too-many-branches 

497 # pylint: disable=too-many-statements,too-many-nested-blocks 

498 # code length and complexity traded for performance, to allow this to be used as a "hot path" 

499 

500 cells: dict[int, tuple[str, int]] = {} 

501 hyperlink_cells: set[int] = set() 

502 sequences: list[tuple[int, int, str]] = [] 

503 seq_order = 0 

504 

505 col = 0 

506 idx = 0 

507 # captured_style is a frozen snapshot of current_style taken at the first 

508 # visible character emitted within the clip window (start, end). It stays 

509 # None until that point. current_style, by contrast, is continuously 

510 # updated by SGR sequences throughout the scan. 

511 # 

512 # When propagate_sgr is False, current_style (and therefore captured_style) 

513 # remain None, and SGR sequences pass through as literal text. 

514 captured_style: Optional[_SGRState] = None 

515 # end_style is the state after the last SGR sequence emitted *within* the 

516 # clip window; it decides the trailing reset. None until such a sequence 

517 # is emitted, meaning captured_style is still in effect at the end. 

518 end_style: Optional[_SGRState] = None 

519 current_style = _SGR_STATE_DEFAULT if propagate_sgr else None 

520 

521 def _write_cells(s: str, w: int, write_col: int, 

522 is_hyperlink: bool = False) -> None: 

523 """Write *w* cells of text *s* at *write_col*, handling wide-char splitting.""" 

524 nonlocal captured_style 

525 for offset in range(w): 

526 src_col = write_col + offset 

527 if src_col > 0 and cells.get(src_col - 1, ('', 0))[1] == 2: 

528 cells[src_col - 1] = (fillchar, 1) 

529 hyperlink_cells.discard(src_col - 1) 

530 if cells.get(src_col, ('', 0))[1] == 2: 

531 cells[src_col + 1] = (fillchar, 1) 

532 hyperlink_cells.discard(src_col + 1) 

533 cells.pop(src_col, None) 

534 hyperlink_cells.discard(src_col) 

535 cells[write_col] = (s, w) 

536 if is_hyperlink: 

537 for offset in range(w): 

538 hyperlink_cells.add(write_col + offset) 

539 if propagate_sgr and captured_style is None: 

540 captured_style = current_style 

541 

542 while idx < len(text): 

543 char = text[idx] 

544 

545 # Early exit: past visible region, SGR captured, no escape ahead. 

546 if col >= end and captured_style is not None and char != '\x1b': 

547 break 

548 

549 if char == '\x1b': 

550 m = _SEQUENCE_CLASSIFY.match(text, idx) 

551 if not m: 

552 # Record lone ESC as a zero-width sequence at current column. 

553 sequences.append((col, seq_order, char)) 

554 seq_order += 1 

555 idx += 1 

556 continue 

557 

558 # SGR: update current_style. Sequences before the first visible 

559 # emission are folded into the prefix synthesized by 

560 # _apply_sgr_wrap(); those inside the clip window are emitted at 

561 # their original position; those at or beyond *end* are dropped. 

562 if m.group('sgr_params') is not None and propagate_sgr and current_style is not None: 

563 current_style = _sgr_state_update(current_style, m.group()) 

564 if captured_style is not None and col < end: 

565 sequences.append((col, seq_order, m.group())) 

566 seq_order += 1 

567 end_style = current_style 

568 idx = m.end() 

569 continue 

570 

571 # OSC 8 hyperlink. 

572 if hl_state := HyperlinkParams.parse(m.group()): 

573 r = _process_hyperlink( 

574 text, start, end, fillchar, tabsize, ambiguous_width, 

575 term_program, 

576 control_codes, 

577 params=hl_state, match_end=m.end(), col=col, 

578 ) 

579 if r.action is _HyperlinkAction.NO_CLOSE: 

580 sequences.append((col, seq_order, m.group())) 

581 seq_order += 1 

582 idx = m.end() 

583 elif r.action is _HyperlinkAction.EMPTY: 

584 idx = r.close_end 

585 elif r.action is _HyperlinkAction.OUTSIDE: 

586 col += r.inner_width 

587 idx = r.close_end 

588 else: 

589 sequences.append((col, seq_order, r.open_seq)) 

590 seq_order += 1 

591 _write_cells(r.clipped_inner, r.clipped_width, col, 

592 is_hyperlink=True) 

593 col += r.clipped_width 

594 sequences.append((col, seq_order, r.close_seq)) 

595 seq_order += 1 

596 # Inner text is clipped with propagate_sgr=False, so any SGR 

597 # sequences it emits are verbatim: fold them into our state. 

598 if propagate_sgr and current_style is not None: 

599 for sgr_m in _SGR_PATTERN.finditer(r.clipped_inner): 

600 current_style = _sgr_state_update(current_style, sgr_m.group()) 

601 end_style = current_style 

602 col = r.hl_col_end 

603 idx = r.close_end 

604 continue 

605 

606 # OSC 66 Text Sizing. 

607 if (ts_meta := m.group('ts_meta')) is not None: 

608 ts_text = m.group('ts_text') 

609 ts_term = m.group('ts_term') 

610 assert ts_text is not None and ts_term is not None 

611 ts = TextSizing( 

612 TextSizingParams.from_params(ts_meta, control_codes=control_codes), 

613 ts_text, ts_term) 

614 col = _text_sizing_clip( 

615 ts, col, start, end, fillchar, ambiguous_width, 

616 term_program, 

617 _write_cells) 

618 idx = m.end() 

619 continue 

620 

621 # Indeterminate-effect sequences: raise in strict mode. 

622 seq = m.group() 

623 if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq): 

624 raise ValueError( 

625 f"Indeterminate cursor sequence at position {idx}, " 

626 f"{seq!r}" 

627 ) 

628 

629 # Horizontal Position Absolute (CSI n G). 

630 if (hpa_n := m.group('hpa_n')) is not None: 

631 col = int(hpa_n) - 1 if hpa_n else 0 

632 idx = m.end() 

633 continue 

634 

635 # Cursor Forward (CSI n C). 

636 if (cforward_n := m.group('cforward_n')) is not None: 

637 n_forward = int(cforward_n) if cforward_n else 1 

638 move_end = col + n_forward 

639 if col < end and move_end > start: 

640 for i in range(max(col, start), min(move_end, end)): 

641 _write_cells(fillchar, 1, i) 

642 col = move_end 

643 idx = m.end() 

644 continue 

645 

646 # Cursor Backward (CSI n D). 

647 if (cbackward_n := m.group('cbackward_n')) is not None: 

648 n_backward = int(cbackward_n) if cbackward_n else 1 

649 if strict and n_backward > col: 

650 raise ValueError( 

651 f"Cursor left movement at position {idx} would move " 

652 f"{n_backward} cells left from column {col}, " 

653 f"exceeding string start" 

654 ) 

655 col -= n_backward 

656 if col < 0: 

657 col = 0 

658 idx = m.end() 

659 continue 

660 

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

662 sequences.append((col, seq_order, m.group())) 

663 seq_order += 1 

664 idx = m.end() 

665 continue 

666 

667 # Carriage return. 

668 if char == '\r': 

669 col = 0 

670 idx += 1 

671 continue 

672 

673 # Backspace. 

674 if char == '\x08': 

675 if col > 0: 

676 col -= 1 

677 idx += 1 

678 continue 

679 

680 # Tab expansion. 

681 if char == '\t': 

682 if tabsize > 0: 

683 next_tab = col + (tabsize - (col % tabsize)) 

684 while col < next_tab: 

685 if start <= col < end: 

686 _write_cells(fillchar, 1, col) 

687 col += 1 

688 else: 

689 sequences.append((col, seq_order, '\t')) 

690 seq_order += 1 

691 idx += 1 

692 continue 

693 

694 # Grapheme cluster. 

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

696 grapheme_w = width(grapheme, ambiguous_width=ambiguous_width, 

697 term_program=term_program) 

698 

699 # Emit grapheme or fillchar depending on visibility within clip window. 

700 if grapheme_w == 0: 

701 if start <= col < end: 

702 sequences.append((col, seq_order, grapheme)) 

703 seq_order += 1 

704 elif col >= start and col + grapheme_w <= end: 

705 _write_cells(grapheme, grapheme_w, col) 

706 elif col < end and col + grapheme_w > start: 

707 clip_start = max(start, col) 

708 for offset in range(min(end, col + grapheme_w) - clip_start): 

709 _write_cells(fillchar, 1, clip_start + offset) 

710 

711 col += grapheme_w 

712 idx += len(grapheme) 

713 

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

715 captured_style, end_style) 

716 

717 

718def clip( 

719 text: str, 

720 start: int, 

721 end: int, 

722 *, 

723 fillchar: str = ' ', 

724 tabsize: int = 8, 

725 ambiguous_width: int = 1, 

726 propagate_sgr: bool = True, 

727 control_codes: Literal['parse', 'strict', 'ignore'] = 'parse', 

728 overtyping: Optional[bool] = None, 

729 term_program: bool | str = False, 

730) -> str: 

731 r""" 

732 Clip text to display columns (start, end) while preserving all terminal sequences. 

733 

734 This function extracts a substring based on visible column positions rather than 

735 character indices. Terminal escape sequences are preserved in the output since 

736 they have zero display width. If a wide character (width 2) is split at 

737 either boundary, it is replaced with ``fillchar``. 

738 

739 TAB characters (``\t``) are expanded to spaces up to the next tab stop, 

740 controlled by the ``tabsize`` parameter. 

741 

742 When cursor movement is detected, a "painter's algorithm" is used unless ``overtyping=False`` is 

743 set. Cursor movement control codes are parsed for their effects instead of ignored. For all 

744 such operations, it is assumed that ``text`` begins at column 0. 

745 

746 **OSC 8 hyperlinks** are handled specially: the visible text inside a hyperlink 

747 is clipped to the requested column range, and the hyperlink is rebuilt around 

748 the clipped text. Empty hyperlinks (those with no remaining visible text after 

749 clipping) are removed:: 

750 

751 >>> clip('\x1b]8;;http://example.com\x07Click This link\x1b]8;;\x07', 6, 10) 

752 '\x1b]8;;http://example.com\x07This\x1b]8;;\x07' 

753 

754 :param text: String to clip, may contain terminal escape sequences. 

755 :param start: Absolute starting column (inclusive, 0-indexed). 

756 :param end: Absolute ending column (exclusive). 

757 :param fillchar: Character to use when a wide character must be split at 

758 a boundary (default space). Must have display width of 1. 

759 :param tabsize: Tab stop width (default 8). Set to 0 to pass tabs through 

760 as zero-width (preserved in output but don't advance column position). 

761 :param ambiguous_width: Width to use for East Asian Ambiguous (A) 

762 characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts. 

763 :param propagate_sgr: If True (default), SGR (terminal styling) sequences 

764 are propagated, matching :func:`propagate_sgr`. The result begins with 

765 any style active at the start position, retains any style changes 

766 occurring within the clipped region at their original position, and 

767 ends with a reset sequence if styles are active at the end position. 

768 :param control_codes: How to handle control characters and sequences: 

769 

770 - ``'parse'`` (default): Track horizontal cursor movement and clip 

771 hyperlink text. Cursor overwrite is always allowed, with best effort 

772 results; indeterminate sequences (home, clear, reset, etc.) are 

773 preserved as zero-width. 

774 - ``'strict'``: Like ``parse``, but raises :exc:`ValueError` on 

775 sequences with indeterminate effects (cursor home, clear screen, 

776 reset, vertical movement, etc.) matching :func:`width` behavior. 

777 Also raises on out-of-bounds horizontal cursor movement. 

778 - ``'ignore'``: All control characters are treated as zero-width. 

779 Cursor movement is not tracked (fastest path). 

780 

781 :param overtyping: Whether to use the painter's algorithm for cursor 

782 movement (``\b`` backspace, ``\r`` carriage return, and CSI cursor 

783 left/right/position sequences). When ``None`` (default), auto-detects 

784 by scanning for these characters in *text*. Set to ``False`` for improved 

785 performance when the caller knows *text* contains no cursor movement 

786 characters. Set to ``True`` to force the painter's algorithm (useful 

787 for testing). Has no effect when ``control_codes='ignore'``. 

788 :param term_program: Terminal software identifier for table correction. 

789 ``False`` (default) disables override lookup. ``True`` reads the 

790 ``TERM_PROGRAM`` or ``TERM`` environment variable for auto-detection. 

791 Accepts a canonical terminal name matching :func:`list_term_programs`, 

792 such as from XTVERSION_, ENQ_, or ``TERM_PROGRAM``. 

793 

794 .. versionadded:: 0.8.0 

795 

796 :returns: Substring of ``text`` spanning display columns (start, end), 

797 with all terminal sequences preserved and wide characters at boundaries 

798 replaced with ``fillchar``. 

799 

800 :raises ValueError: If ``control_codes='strict'`` and an indeterminate-effect 

801 sequence or out-of-bounds cursor movement is encountered. 

802 

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

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

805 

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

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

808 >>> wcwidth.clip('\x1b[1mbold\x1b[m normal', 1, 9) 

809 '\x1b[1mold\x1b[m norm' 

810 

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

812 

813 .. versionadded:: 0.3.0 

814 

815 .. versionchanged:: 0.5.0 

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

817 

818 .. versionchanged:: 0.7.0 

819 Added ``control_codes`` parameter (default 'parse'). 

820 OSC 8 hyperlink-aware clipping. OSC 66 text sizing protocol support. 

821 Added ``overtyping`` parameter (default None, auto-detect). 

822 

823 Example:: 

824 

825 >>> clip('hello world', 0, 5) 

826 'hello' 

827 >>> clip('中文字', 0, 3) # Wide char split at column 3 

828 '中 ' 

829 >>> clip('a\tb', 0, 10) # Tab expanded to spaces 

830 'a b' 

831 """ 

832 start = max(start, 0) 

833 if end <= start: 

834 return '' 

835 

836 # Fast path: printable ASCII only. 

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

838 return text[start:end] 

839 

840 # No escape sequences => no SGR tracking needed. 

841 has_esc = '\x1b' in text 

842 if propagate_sgr and not has_esc: 

843 propagate_sgr = False 

844 

845 # Determine whether painter's algorithm is needed. 

846 if overtyping is None: 

847 # Auto-detect: scan for cursor movement characters. 

848 overtyping = ( 

849 control_codes != 'ignore' and 

850 ('\x08' in text or '\r' in text or 

851 (has_esc and bool(_HORIZONTAL_CURSOR_MOVEMENT.search(text)))) 

852 ) 

853 elif overtyping and control_codes == 'ignore': 

854 overtyping = False # control_codes='ignore' overrides 

855 fn_clip = _clip_painter if overtyping else _clip_simple 

856 

857 return _apply_sgr_wrap(*fn_clip( 

858 text=text, 

859 start=start, 

860 end=end, 

861 propagate_sgr=propagate_sgr, 

862 ambiguous_width=ambiguous_width, 

863 term_program=term_program, 

864 fillchar=fillchar, 

865 tabsize=tabsize, 

866 strict=(control_codes == 'strict'), 

867 control_codes=control_codes, 

868 ))