Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/prompt_toolkit/document.py: 23%

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

529 statements  

1""" 

2The `Document` that implements all the text operations/querying. 

3""" 

4 

5from __future__ import annotations 

6 

7import bisect 

8import re 

9import string 

10import weakref 

11from typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast 

12 

13from .clipboard import ClipboardData 

14from .filters import vi_mode 

15from .selection import PasteMode, SelectionState, SelectionType 

16 

17__all__ = [ 

18 "Document", 

19] 

20 

21 

22# Regex for finding "words" in documents. (We consider a group of alnum 

23# characters a word, but also a group of special characters a word, as long as 

24# it doesn't contain a space.) 

25# (This is a 'word' in Vi.) 

26_FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") 

27_FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") 

28_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( 

29 r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)" 

30) 

31 

32# Regex for finding "WORDS" in documents. 

33# (This is a 'WORD in Vi.) 

34_FIND_BIG_WORD_RE = re.compile(r"([^\s]+)") 

35_FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)") 

36_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)") 

37 

38# Share the Document._cache between all Document instances. 

39# (Document instances are considered immutable. That means that if another 

40# `Document` is constructed with the same text, it should have the same 

41# `_DocumentCache`.) 

42_text_to_document_cache: dict[str, _DocumentCache] = cast( 

43 Dict[str, "_DocumentCache"], 

44 weakref.WeakValueDictionary(), # Maps document.text to DocumentCache instance. 

45) 

46 

47 

48class _ImmutableLineList(List[str]): 

49 """ 

50 Some protection for our 'lines' list, which is assumed to be immutable in the cache. 

51 (Useful for detecting obvious bugs.) 

52 """ 

53 

54 def _error(self, *a: object, **kw: object) -> NoReturn: 

55 raise NotImplementedError("Attempt to modify an immutable list.") 

56 

57 __setitem__ = _error # type: ignore 

58 append = _error 

59 clear = _error 

60 extend = _error 

61 insert = _error 

62 pop = _error 

63 remove = _error 

64 reverse = _error 

65 sort = _error # type: ignore 

66 

67 

68class _DocumentCache: 

69 def __init__(self) -> None: 

70 #: List of lines for the Document text. 

71 self.lines: _ImmutableLineList | None = None 

72 

73 #: List of index positions, pointing to the start of all the lines. 

74 self.line_indexes: list[int] | None = None 

75 

76 

77class Document: 

78 """ 

79 This is a immutable class around the text and cursor position, and contains 

80 methods for querying this data, e.g. to give the text before the cursor. 

81 

82 This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer` 

83 object, and accessed as the `document` property of that class. 

84 

85 :param text: string 

86 :param cursor_position: int 

87 :param selection: :class:`.SelectionState` 

88 """ 

89 

90 __slots__ = ("_text", "_cursor_position", "_selection", "_cache") 

91 

92 def __init__( 

93 self, 

94 text: str = "", 

95 cursor_position: int | None = None, 

96 selection: SelectionState | None = None, 

97 ) -> None: 

98 # Check cursor position. It can also be right after the end. (Where we 

99 # insert text.) 

100 assert cursor_position is None or cursor_position <= len(text), AssertionError( 

101 f"cursor_position={cursor_position!r}, len_text={len(text)!r}" 

102 ) 

103 

104 # By default, if no cursor position was given, make sure to put the 

105 # cursor position is at the end of the document. This is what makes 

106 # sense in most places. 

107 if cursor_position is None: 

108 cursor_position = len(text) 

109 

110 # Keep these attributes private. A `Document` really has to be 

111 # considered to be immutable, because otherwise the caching will break 

112 # things. Because of that, we wrap these into read-only properties. 

113 self._text = text 

114 self._cursor_position = cursor_position 

115 self._selection = selection 

116 

117 # Cache for lines/indexes. (Shared with other Document instances that 

118 # contain the same text. 

119 try: 

120 self._cache = _text_to_document_cache[self.text] 

121 except KeyError: 

122 self._cache = _DocumentCache() 

123 _text_to_document_cache[self.text] = self._cache 

124 

125 # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'. 

126 # This fails in Pypy3. `self._cache` becomes None, because that's what 

127 # 'setdefault' returns. 

128 # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache()) 

129 # assert self._cache 

130 

131 def __repr__(self) -> str: 

132 return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})" 

133 

134 def __eq__(self, other: object) -> bool: 

135 if not isinstance(other, Document): 

136 return False 

137 

138 return ( 

139 self.text == other.text 

140 and self.cursor_position == other.cursor_position 

141 and self.selection == other.selection 

142 ) 

143 

144 @property 

145 def text(self) -> str: 

146 "The document text." 

147 return self._text 

148 

149 @property 

150 def cursor_position(self) -> int: 

151 "The document cursor position." 

152 return self._cursor_position 

153 

154 @property 

155 def selection(self) -> SelectionState | None: 

156 ":class:`.SelectionState` object." 

157 return self._selection 

158 

159 @property 

160 def current_char(self) -> str: 

161 """Return character under cursor or an empty string.""" 

162 return self._get_char_relative_to_cursor(0) or "" 

163 

164 @property 

165 def char_before_cursor(self) -> str: 

166 """Return character before the cursor or an empty string.""" 

167 return self._get_char_relative_to_cursor(-1) or "" 

168 

169 @property 

170 def text_before_cursor(self) -> str: 

171 return self.text[: self.cursor_position :] 

172 

173 @property 

174 def text_after_cursor(self) -> str: 

175 return self.text[self.cursor_position :] 

176 

177 @property 

178 def current_line_before_cursor(self) -> str: 

179 """Text from the start of the line until the cursor.""" 

180 _, _, text = self.text_before_cursor.rpartition("\n") 

181 return text 

182 

183 @property 

184 def current_line_after_cursor(self) -> str: 

185 """Text from the cursor until the end of the line.""" 

186 text, _, _ = self.text_after_cursor.partition("\n") 

187 return text 

188 

189 @property 

190 def lines(self) -> list[str]: 

191 """ 

192 Array of all the lines. 

193 """ 

194 # Cache, because this one is reused very often. 

195 if self._cache.lines is None: 

196 self._cache.lines = _ImmutableLineList(self.text.split("\n")) 

197 

198 return self._cache.lines 

199 

200 @property 

201 def _line_start_indexes(self) -> list[int]: 

202 """ 

203 Array pointing to the start indexes of all the lines. 

204 """ 

205 # Cache, because this is often reused. (If it is used, it's often used 

206 # many times. And this has to be fast for editing big documents!) 

207 if self._cache.line_indexes is None: 

208 # Create list of line lengths. 

209 line_lengths = map(len, self.lines) 

210 

211 # Calculate cumulative sums. 

212 indexes = [0] 

213 append = indexes.append 

214 pos = 0 

215 

216 for line_length in line_lengths: 

217 pos += line_length + 1 

218 append(pos) 

219 

220 # Remove the last item. (This is not a new line.) 

221 if len(indexes) > 1: 

222 indexes.pop() 

223 

224 self._cache.line_indexes = indexes 

225 

226 return self._cache.line_indexes 

227 

228 @property 

229 def lines_from_current(self) -> list[str]: 

230 """ 

231 Array of the lines starting from the current line, until the last line. 

232 """ 

233 return self.lines[self.cursor_position_row :] 

234 

235 @property 

236 def line_count(self) -> int: 

237 r"""Return the number of lines in this document. If the document ends 

238 with a trailing \n, that counts as the beginning of a new line.""" 

239 return len(self.lines) 

240 

241 @property 

242 def current_line(self) -> str: 

243 """Return the text on the line where the cursor is. (when the input 

244 consists of just one line, it equals `text`.""" 

245 return self.current_line_before_cursor + self.current_line_after_cursor 

246 

247 @property 

248 def leading_whitespace_in_current_line(self) -> str: 

249 """The leading whitespace in the left margin of the current line.""" 

250 current_line = self.current_line 

251 length = len(current_line) - len(current_line.lstrip()) 

252 return current_line[:length] 

253 

254 def _get_char_relative_to_cursor(self, offset: int = 0) -> str: 

255 """ 

256 Return character relative to cursor position, or empty string 

257 """ 

258 try: 

259 return self.text[self.cursor_position + offset] 

260 except IndexError: 

261 return "" 

262 

263 @property 

264 def on_first_line(self) -> bool: 

265 """ 

266 True when we are at the first line. 

267 """ 

268 return self.cursor_position_row == 0 

269 

270 @property 

271 def on_last_line(self) -> bool: 

272 """ 

273 True when we are at the last line. 

274 """ 

275 return self.cursor_position_row == self.line_count - 1 

276 

277 @property 

278 def cursor_position_row(self) -> int: 

279 """ 

280 Current row. (0-based.) 

281 """ 

282 row, _ = self._find_line_start_index(self.cursor_position) 

283 return row 

284 

285 @property 

286 def cursor_position_col(self) -> int: 

287 """ 

288 Current column. (0-based.) 

289 """ 

290 # (Don't use self.text_before_cursor to calculate this. Creating 

291 # substrings and doing rsplit is too expensive for getting the cursor 

292 # position.) 

293 _, line_start_index = self._find_line_start_index(self.cursor_position) 

294 return self.cursor_position - line_start_index 

295 

296 def _find_line_start_index(self, index: int) -> tuple[int, int]: 

297 """ 

298 For the index of a character at a certain line, calculate the index of 

299 the first character on that line. 

300 

301 Return (row, index) tuple. 

302 """ 

303 indexes = self._line_start_indexes 

304 

305 pos = bisect.bisect_right(indexes, index) - 1 

306 return pos, indexes[pos] 

307 

308 def translate_index_to_position(self, index: int) -> tuple[int, int]: 

309 """ 

310 Given an index for the text, return the corresponding (row, col) tuple. 

311 (0-based. Returns (0, 0) for index=0.) 

312 """ 

313 # Find start of this line. 

314 row, row_index = self._find_line_start_index(index) 

315 col = index - row_index 

316 

317 return row, col 

318 

319 def translate_row_col_to_index(self, row: int, col: int) -> int: 

320 """ 

321 Given a (row, col) tuple, return the corresponding index. 

322 (Row and col params are 0-based.) 

323 

324 Negative row/col values are turned into zero. 

325 """ 

326 try: 

327 result = self._line_start_indexes[row] 

328 line = self.lines[row] 

329 except IndexError: 

330 if row < 0: 

331 result = self._line_start_indexes[0] 

332 line = self.lines[0] 

333 else: 

334 result = self._line_start_indexes[-1] 

335 line = self.lines[-1] 

336 

337 result += max(0, min(col, len(line))) 

338 

339 # Keep in range. (len(self.text) is included, because the cursor can be 

340 # right after the end of the text as well.) 

341 result = max(0, min(result, len(self.text))) 

342 return result 

343 

344 @property 

345 def is_cursor_at_the_end(self) -> bool: 

346 """True when the cursor is at the end of the text.""" 

347 return self.cursor_position == len(self.text) 

348 

349 @property 

350 def is_cursor_at_the_end_of_line(self) -> bool: 

351 """True when the cursor is at the end of this line.""" 

352 return self.current_char in ("\n", "") 

353 

354 def has_match_at_current_position(self, sub: str) -> bool: 

355 """ 

356 `True` when this substring is found at the cursor position. 

357 """ 

358 return self.text.find(sub, self.cursor_position) == self.cursor_position 

359 

360 def find( 

361 self, 

362 sub: str, 

363 in_current_line: bool = False, 

364 include_current_position: bool = False, 

365 ignore_case: bool = False, 

366 count: int = 1, 

367 ) -> int | None: 

368 """ 

369 Find `text` after the cursor, return position relative to the cursor 

370 position. Return `None` if nothing was found. 

371 

372 :param count: Find the n-th occurrence. 

373 """ 

374 assert isinstance(ignore_case, bool) 

375 

376 if in_current_line: 

377 text = self.current_line_after_cursor 

378 else: 

379 text = self.text_after_cursor 

380 

381 if not include_current_position: 

382 if len(text) == 0: 

383 return None # (Otherwise, we always get a match for the empty string.) 

384 else: 

385 text = text[1:] 

386 

387 flags = re.IGNORECASE if ignore_case else 0 

388 iterator = re.finditer(re.escape(sub), text, flags) 

389 

390 try: 

391 for i, match in enumerate(iterator): 

392 if i + 1 == count: 

393 if include_current_position: 

394 return match.start(0) 

395 else: 

396 return match.start(0) + 1 

397 except StopIteration: 

398 pass 

399 return None 

400 

401 def find_all(self, sub: str, ignore_case: bool = False) -> list[int]: 

402 """ 

403 Find all occurrences of the substring. Return a list of absolute 

404 positions in the document. 

405 """ 

406 flags = re.IGNORECASE if ignore_case else 0 

407 return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)] 

408 

409 def find_backwards( 

410 self, 

411 sub: str, 

412 in_current_line: bool = False, 

413 ignore_case: bool = False, 

414 count: int = 1, 

415 ) -> int | None: 

416 """ 

417 Find `text` before the cursor, return position relative to the cursor 

418 position. Return `None` if nothing was found. 

419 

420 :param count: Find the n-th occurrence. 

421 """ 

422 if in_current_line: 

423 before_cursor = self.current_line_before_cursor[::-1] 

424 else: 

425 before_cursor = self.text_before_cursor[::-1] 

426 

427 flags = re.IGNORECASE if ignore_case else 0 

428 iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags) 

429 

430 try: 

431 for i, match in enumerate(iterator): 

432 if i + 1 == count: 

433 return -match.start(0) - len(sub) 

434 except StopIteration: 

435 pass 

436 return None 

437 

438 def get_word_before_cursor( 

439 self, WORD: bool = False, pattern: Pattern[str] | None = None 

440 ) -> str: 

441 """ 

442 Give the word before the cursor. 

443 If we have whitespace before the cursor this returns an empty string. 

444 

445 :param pattern: (None or compiled regex). When given, use this regex 

446 pattern. 

447 """ 

448 if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern): 

449 # Space before the cursor or no text before cursor. 

450 return "" 

451 

452 text_before_cursor = self.text_before_cursor 

453 start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0 

454 

455 return text_before_cursor[len(text_before_cursor) + start :] 

456 

457 def _is_word_before_cursor_complete( 

458 self, WORD: bool = False, pattern: Pattern[str] | None = None 

459 ) -> bool: 

460 if pattern: 

461 return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None 

462 else: 

463 return ( 

464 self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace() 

465 ) 

466 

467 def find_start_of_previous_word( 

468 self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None 

469 ) -> int | None: 

470 """ 

471 Return an index relative to the cursor position pointing to the start 

472 of the previous word. Return `None` if nothing was found. 

473 

474 :param pattern: (None or compiled regex). When given, use this regex 

475 pattern. 

476 """ 

477 assert not (WORD and pattern) 

478 

479 # Reverse the text before the cursor, in order to do an efficient 

480 # backwards search. 

481 text_before_cursor = self.text_before_cursor[::-1] 

482 

483 if pattern: 

484 regex = pattern 

485 elif WORD: 

486 regex = _FIND_BIG_WORD_RE 

487 else: 

488 regex = _FIND_WORD_RE 

489 

490 iterator = regex.finditer(text_before_cursor) 

491 

492 try: 

493 for i, match in enumerate(iterator): 

494 if i + 1 == count: 

495 return -match.end(0) 

496 except StopIteration: 

497 pass 

498 return None 

499 

500 def find_boundaries_of_current_word( 

501 self, 

502 WORD: bool = False, 

503 include_leading_whitespace: bool = False, 

504 include_trailing_whitespace: bool = False, 

505 ) -> tuple[int, int]: 

506 """ 

507 Return the relative boundaries (startpos, endpos) of the current word under the 

508 cursor. (This is at the current line, because line boundaries obviously 

509 don't belong to any word.) 

510 If not on a word, this returns (0,0) 

511 """ 

512 text_before_cursor = self.current_line_before_cursor[::-1] 

513 text_after_cursor = self.current_line_after_cursor 

514 

515 def get_regex(include_whitespace: bool) -> Pattern[str]: 

516 return { 

517 (False, False): _FIND_CURRENT_WORD_RE, 

518 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

519 (True, False): _FIND_CURRENT_BIG_WORD_RE, 

520 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

521 }[(WORD, include_whitespace)] 

522 

523 match_before = get_regex(include_leading_whitespace).search(text_before_cursor) 

524 match_after = get_regex(include_trailing_whitespace).search(text_after_cursor) 

525 

526 # When there is a match before and after, and we're not looking for 

527 # WORDs, make sure that both the part before and after the cursor are 

528 # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part 

529 # before the cursor. 

530 if not WORD and match_before and match_after: 

531 c1 = self.text[self.cursor_position - 1] 

532 c2 = self.text[self.cursor_position] 

533 alphabet = string.ascii_letters + "0123456789_" 

534 

535 if (c1 in alphabet) != (c2 in alphabet): 

536 match_before = None 

537 

538 return ( 

539 -match_before.end(1) if match_before else 0, 

540 match_after.end(1) if match_after else 0, 

541 ) 

542 

543 def get_word_under_cursor(self, WORD: bool = False) -> str: 

544 """ 

545 Return the word, currently below the cursor. 

546 This returns an empty string when the cursor is on a whitespace region. 

547 """ 

548 start, end = self.find_boundaries_of_current_word(WORD=WORD) 

549 return self.text[self.cursor_position + start : self.cursor_position + end] 

550 

551 def find_next_word_beginning( 

552 self, count: int = 1, WORD: bool = False 

553 ) -> int | None: 

554 """ 

555 Return an index relative to the cursor position pointing to the start 

556 of the next word. Return `None` if nothing was found. 

557 """ 

558 if count < 0: 

559 return self.find_previous_word_beginning(count=-count, WORD=WORD) 

560 

561 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

562 iterator = regex.finditer(self.text_after_cursor) 

563 

564 try: 

565 for i, match in enumerate(iterator): 

566 # Take first match, unless it's the word on which we're right now. 

567 if i == 0 and match.start(1) == 0: 

568 count += 1 

569 

570 if i + 1 == count: 

571 return match.start(1) 

572 except StopIteration: 

573 pass 

574 return None 

575 

576 def find_next_word_ending( 

577 self, include_current_position: bool = False, count: int = 1, WORD: bool = False 

578 ) -> int | None: 

579 """ 

580 Return an index relative to the cursor position pointing to the end 

581 of the next word. Return `None` if nothing was found. 

582 """ 

583 if count < 0: 

584 return self.find_previous_word_ending(count=-count, WORD=WORD) 

585 

586 if include_current_position: 

587 text = self.text_after_cursor 

588 else: 

589 text = self.text_after_cursor[1:] 

590 

591 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

592 iterable = regex.finditer(text) 

593 

594 try: 

595 for i, match in enumerate(iterable): 

596 if i + 1 == count: 

597 value = match.end(1) 

598 

599 if include_current_position: 

600 return value 

601 else: 

602 return value + 1 

603 

604 except StopIteration: 

605 pass 

606 return None 

607 

608 def find_previous_word_beginning( 

609 self, count: int = 1, WORD: bool = False 

610 ) -> int | None: 

611 """ 

612 Return an index relative to the cursor position pointing to the start 

613 of the previous word. Return `None` if nothing was found. 

614 """ 

615 if count < 0: 

616 return self.find_next_word_beginning(count=-count, WORD=WORD) 

617 

618 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

619 iterator = regex.finditer(self.text_before_cursor[::-1]) 

620 

621 try: 

622 for i, match in enumerate(iterator): 

623 if i + 1 == count: 

624 return -match.end(1) 

625 except StopIteration: 

626 pass 

627 return None 

628 

629 def find_previous_word_ending( 

630 self, count: int = 1, WORD: bool = False 

631 ) -> int | None: 

632 """ 

633 Return an index relative to the cursor position pointing to the end 

634 of the previous word. Return `None` if nothing was found. 

635 """ 

636 if count < 0: 

637 return self.find_next_word_ending(count=-count, WORD=WORD) 

638 

639 text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1] 

640 

641 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

642 iterator = regex.finditer(text_before_cursor) 

643 

644 try: 

645 for i, match in enumerate(iterator): 

646 # Take first match, unless it's the word on which we're right now. 

647 if i == 0 and match.start(1) == 0: 

648 count += 1 

649 

650 if i + 1 == count: 

651 return -match.start(1) + 1 

652 except StopIteration: 

653 pass 

654 return None 

655 

656 def find_next_matching_line( 

657 self, match_func: Callable[[str], bool], count: int = 1 

658 ) -> int | None: 

659 """ 

660 Look downwards for empty lines. 

661 Return the line index, relative to the current line. 

662 """ 

663 result = None 

664 

665 for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]): 

666 if match_func(line): 

667 result = 1 + index 

668 count -= 1 

669 

670 if count == 0: 

671 break 

672 

673 return result 

674 

675 def find_previous_matching_line( 

676 self, match_func: Callable[[str], bool], count: int = 1 

677 ) -> int | None: 

678 """ 

679 Look upwards for empty lines. 

680 Return the line index, relative to the current line. 

681 """ 

682 result = None 

683 

684 for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]): 

685 if match_func(line): 

686 result = -1 - index 

687 count -= 1 

688 

689 if count == 0: 

690 break 

691 

692 return result 

693 

694 def get_cursor_left_position(self, count: int = 1) -> int: 

695 """ 

696 Relative position for cursor left. 

697 """ 

698 if count < 0: 

699 return self.get_cursor_right_position(-count) 

700 

701 return -min(self.cursor_position_col, count) 

702 

703 def get_cursor_right_position(self, count: int = 1) -> int: 

704 """ 

705 Relative position for cursor_right. 

706 """ 

707 if count < 0: 

708 return self.get_cursor_left_position(-count) 

709 

710 return min(count, len(self.current_line_after_cursor)) 

711 

712 def get_cursor_up_position( 

713 self, count: int = 1, preferred_column: int | None = None 

714 ) -> int: 

715 """ 

716 Return the relative cursor position (character index) where we would be if the 

717 user pressed the arrow-up button. 

718 

719 :param preferred_column: When given, go to this column instead of 

720 staying at the current column. 

721 """ 

722 assert count >= 1 

723 column = ( 

724 self.cursor_position_col if preferred_column is None else preferred_column 

725 ) 

726 

727 return ( 

728 self.translate_row_col_to_index( 

729 max(0, self.cursor_position_row - count), column 

730 ) 

731 - self.cursor_position 

732 ) 

733 

734 def get_cursor_down_position( 

735 self, count: int = 1, preferred_column: int | None = None 

736 ) -> int: 

737 """ 

738 Return the relative cursor position (character index) where we would be if the 

739 user pressed the arrow-down button. 

740 

741 :param preferred_column: When given, go to this column instead of 

742 staying at the current column. 

743 """ 

744 assert count >= 1 

745 column = ( 

746 self.cursor_position_col if preferred_column is None else preferred_column 

747 ) 

748 

749 return ( 

750 self.translate_row_col_to_index(self.cursor_position_row + count, column) 

751 - self.cursor_position 

752 ) 

753 

754 def find_enclosing_bracket_right( 

755 self, left_ch: str, right_ch: str, end_pos: int | None = None 

756 ) -> int | None: 

757 """ 

758 Find the right bracket enclosing current position. Return the relative 

759 position to the cursor position. 

760 

761 When `end_pos` is given, don't look past the position. 

762 """ 

763 if self.current_char == right_ch: 

764 return 0 

765 

766 if end_pos is None: 

767 end_pos = len(self.text) 

768 else: 

769 end_pos = min(len(self.text), end_pos) 

770 

771 stack = 1 

772 

773 # Look forward. 

774 for i in range(self.cursor_position + 1, end_pos): 

775 c = self.text[i] 

776 

777 if c == left_ch: 

778 stack += 1 

779 elif c == right_ch: 

780 stack -= 1 

781 

782 if stack == 0: 

783 return i - self.cursor_position 

784 

785 return None 

786 

787 def find_enclosing_bracket_left( 

788 self, left_ch: str, right_ch: str, start_pos: int | None = None 

789 ) -> int | None: 

790 """ 

791 Find the left bracket enclosing current position. Return the relative 

792 position to the cursor position. 

793 

794 When `start_pos` is given, don't look past the position. 

795 """ 

796 if self.current_char == left_ch: 

797 return 0 

798 

799 if start_pos is None: 

800 start_pos = 0 

801 else: 

802 start_pos = max(0, start_pos) 

803 

804 stack = 1 

805 

806 # Look backward. 

807 for i in range(self.cursor_position - 1, start_pos - 1, -1): 

808 c = self.text[i] 

809 

810 if c == right_ch: 

811 stack += 1 

812 elif c == left_ch: 

813 stack -= 1 

814 

815 if stack == 0: 

816 return i - self.cursor_position 

817 

818 return None 

819 

820 def find_matching_bracket_position( 

821 self, start_pos: int | None = None, end_pos: int | None = None 

822 ) -> int: 

823 """ 

824 Return relative cursor position of matching [, (, { or < bracket. 

825 

826 When `start_pos` or `end_pos` are given. Don't look past the positions. 

827 """ 

828 

829 # Look for a match. 

830 for pair in "()", "[]", "{}", "<>": 

831 A = pair[0] 

832 B = pair[1] 

833 if self.current_char == A: 

834 return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0 

835 elif self.current_char == B: 

836 return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0 

837 

838 return 0 

839 

840 def get_start_of_document_position(self) -> int: 

841 """Relative position for the start of the document.""" 

842 return -self.cursor_position 

843 

844 def get_end_of_document_position(self) -> int: 

845 """Relative position for the end of the document.""" 

846 return len(self.text) - self.cursor_position 

847 

848 def get_start_of_line_position(self, after_whitespace: bool = False) -> int: 

849 """Relative position for the start of this line.""" 

850 if after_whitespace: 

851 current_line = self.current_line 

852 return ( 

853 len(current_line) 

854 - len(current_line.lstrip()) 

855 - self.cursor_position_col 

856 ) 

857 else: 

858 return -len(self.current_line_before_cursor) 

859 

860 def get_end_of_line_position(self) -> int: 

861 """Relative position for the end of this line.""" 

862 return len(self.current_line_after_cursor) 

863 

864 def last_non_blank_of_current_line_position(self) -> int: 

865 """ 

866 Relative position for the last non blank character of this line. 

867 """ 

868 return len(self.current_line.rstrip()) - self.cursor_position_col - 1 

869 

870 def get_column_cursor_position(self, column: int) -> int: 

871 """ 

872 Return the relative cursor position for this column at the current 

873 line. (It will stay between the boundaries of the line in case of a 

874 larger number.) 

875 """ 

876 line_length = len(self.current_line) 

877 current_column = self.cursor_position_col 

878 column = max(0, min(line_length, column)) 

879 

880 return column - current_column 

881 

882 def selection_range( 

883 self, 

884 ) -> tuple[ 

885 int, int 

886 ]: # XXX: shouldn't this return `None` if there is no selection??? 

887 """ 

888 Return (from, to) tuple of the selection. 

889 start and end position are included. 

890 

891 This doesn't take the selection type into account. Use 

892 `selection_ranges` instead. 

893 """ 

894 if self.selection: 

895 from_, to = sorted( 

896 [self.cursor_position, self.selection.original_cursor_position] 

897 ) 

898 else: 

899 from_, to = self.cursor_position, self.cursor_position 

900 

901 return from_, to 

902 

903 def selection_ranges(self) -> Iterable[tuple[int, int]]: 

904 """ 

905 Return a list of `(from, to)` tuples for the selection or none if 

906 nothing was selected. The upper boundary is not included. 

907 

908 This will yield several (from, to) tuples in case of a BLOCK selection. 

909 This will return zero ranges, like (8,8) for empty lines in a block 

910 selection. 

911 """ 

912 if self.selection: 

913 from_, to = sorted( 

914 [self.cursor_position, self.selection.original_cursor_position] 

915 ) 

916 

917 if self.selection.type == SelectionType.BLOCK: 

918 from_line, from_column = self.translate_index_to_position(from_) 

919 to_line, to_column = self.translate_index_to_position(to) 

920 from_column, to_column = sorted([from_column, to_column]) 

921 lines = self.lines 

922 

923 if vi_mode(): 

924 to_column += 1 

925 

926 for l in range(from_line, to_line + 1): 

927 line_length = len(lines[l]) 

928 

929 if from_column <= line_length: 

930 yield ( 

931 self.translate_row_col_to_index(l, from_column), 

932 self.translate_row_col_to_index( 

933 l, min(line_length, to_column) 

934 ), 

935 ) 

936 else: 

937 # In case of a LINES selection, go to the start/end of the lines. 

938 if self.selection.type == SelectionType.LINES: 

939 from_ = max(0, self.text.rfind("\n", 0, from_) + 1) 

940 

941 if self.text.find("\n", to) >= 0: 

942 to = self.text.find("\n", to) 

943 else: 

944 to = len(self.text) - 1 

945 

946 # In Vi mode, the upper boundary is always included. For Emacs, 

947 # that's not the case. 

948 if vi_mode(): 

949 to += 1 

950 

951 yield from_, to 

952 

953 def selection_range_at_line(self, row: int) -> tuple[int, int] | None: 

954 """ 

955 If the selection spans a portion of the given line, return a (from, to) tuple. 

956 

957 The returned upper boundary is not included in the selection, so 

958 `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection. 

959 

960 Returns None if the selection doesn't cover this line at all. 

961 """ 

962 if self.selection: 

963 line = self.lines[row] 

964 

965 row_start = self.translate_row_col_to_index(row, 0) 

966 row_end = self.translate_row_col_to_index(row, len(line)) 

967 

968 from_, to = sorted( 

969 [self.cursor_position, self.selection.original_cursor_position] 

970 ) 

971 

972 # Take the intersection of the current line and the selection. 

973 intersection_start = max(row_start, from_) 

974 intersection_end = min(row_end, to) 

975 

976 if intersection_start <= intersection_end: 

977 if self.selection.type == SelectionType.LINES: 

978 intersection_start = row_start 

979 intersection_end = row_end 

980 

981 elif self.selection.type == SelectionType.BLOCK: 

982 _, col1 = self.translate_index_to_position(from_) 

983 _, col2 = self.translate_index_to_position(to) 

984 col1, col2 = sorted([col1, col2]) 

985 

986 if col1 > len(line): 

987 return None # Block selection doesn't cross this line. 

988 

989 intersection_start = self.translate_row_col_to_index(row, col1) 

990 intersection_end = self.translate_row_col_to_index(row, col2) 

991 

992 _, from_column = self.translate_index_to_position(intersection_start) 

993 _, to_column = self.translate_index_to_position(intersection_end) 

994 

995 # In Vi mode, the upper boundary is always included. For Emacs 

996 # mode, that's not the case. 

997 if vi_mode(): 

998 to_column += 1 

999 

1000 return from_column, to_column 

1001 return None 

1002 

1003 def cut_selection(self) -> tuple[Document, ClipboardData]: 

1004 """ 

1005 Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the 

1006 document represents the new document when the selection is cut, and the 

1007 clipboard data, represents whatever has to be put on the clipboard. 

1008 """ 

1009 if self.selection: 

1010 cut_parts = [] 

1011 remaining_parts = [] 

1012 new_cursor_position = self.cursor_position 

1013 

1014 last_to = 0 

1015 for from_, to in self.selection_ranges(): 

1016 if last_to == 0: 

1017 new_cursor_position = from_ 

1018 

1019 remaining_parts.append(self.text[last_to:from_]) 

1020 cut_parts.append(self.text[from_:to]) 

1021 last_to = to 

1022 

1023 remaining_parts.append(self.text[last_to:]) 

1024 

1025 cut_text = "\n".join(cut_parts) 

1026 remaining_text = "".join(remaining_parts) 

1027 

1028 # In case of a LINES selection, don't include the trailing newline. 

1029 if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"): 

1030 cut_text = cut_text[:-1] 

1031 

1032 return ( 

1033 Document(text=remaining_text, cursor_position=new_cursor_position), 

1034 ClipboardData(cut_text, self.selection.type), 

1035 ) 

1036 else: 

1037 return self, ClipboardData("") 

1038 

1039 def paste_clipboard_data( 

1040 self, 

1041 data: ClipboardData, 

1042 paste_mode: PasteMode = PasteMode.EMACS, 

1043 count: int = 1, 

1044 ) -> Document: 

1045 """ 

1046 Return a new :class:`.Document` instance which contains the result if 

1047 we would paste this data at the current cursor position. 

1048 

1049 :param paste_mode: Where to paste. (Before/after/emacs.) 

1050 :param count: When >1, Paste multiple times. 

1051 """ 

1052 before = paste_mode == PasteMode.VI_BEFORE 

1053 after = paste_mode == PasteMode.VI_AFTER 

1054 

1055 if data.type == SelectionType.CHARACTERS: 

1056 if after: 

1057 new_text = ( 

1058 self.text[: self.cursor_position + 1] 

1059 + data.text * count 

1060 + self.text[self.cursor_position + 1 :] 

1061 ) 

1062 else: 

1063 new_text = ( 

1064 self.text_before_cursor + data.text * count + self.text_after_cursor 

1065 ) 

1066 

1067 new_cursor_position = self.cursor_position + len(data.text) * count 

1068 if before: 

1069 new_cursor_position -= 1 

1070 

1071 elif data.type == SelectionType.LINES: 

1072 l = self.cursor_position_row 

1073 if before: 

1074 lines = self.lines[:l] + [data.text] * count + self.lines[l:] 

1075 new_text = "\n".join(lines) 

1076 new_cursor_position = len("".join(self.lines[:l])) + l 

1077 else: 

1078 lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :] 

1079 new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1 

1080 new_text = "\n".join(lines) 

1081 

1082 elif data.type == SelectionType.BLOCK: 

1083 lines = self.lines[:] 

1084 start_line = self.cursor_position_row 

1085 start_column = self.cursor_position_col + (0 if before else 1) 

1086 

1087 for i, line in enumerate(data.text.split("\n")): 

1088 index = i + start_line 

1089 if index >= len(lines): 

1090 lines.append("") 

1091 

1092 lines[index] = lines[index].ljust(start_column) 

1093 lines[index] = ( 

1094 lines[index][:start_column] 

1095 + line * count 

1096 + lines[index][start_column:] 

1097 ) 

1098 

1099 new_text = "\n".join(lines) 

1100 new_cursor_position = self.cursor_position + (0 if before else 1) 

1101 

1102 return Document(text=new_text, cursor_position=new_cursor_position) 

1103 

1104 def empty_line_count_at_the_end(self) -> int: 

1105 """ 

1106 Return number of empty lines at the end of the document. 

1107 """ 

1108 count = 0 

1109 for line in self.lines[::-1]: 

1110 if not line or line.isspace(): 

1111 count += 1 

1112 else: 

1113 break 

1114 

1115 return count 

1116 

1117 def start_of_paragraph(self, count: int = 1, before: bool = False) -> int: 

1118 """ 

1119 Return the start of the current paragraph. (Relative cursor position.) 

1120 """ 

1121 

1122 def match_func(text: str) -> bool: 

1123 return not text or text.isspace() 

1124 

1125 line_index = self.find_previous_matching_line( 

1126 match_func=match_func, count=count 

1127 ) 

1128 

1129 if line_index: 

1130 add = 0 if before else 1 

1131 return min(0, self.get_cursor_up_position(count=-line_index) + add) 

1132 else: 

1133 return -self.cursor_position 

1134 

1135 def end_of_paragraph(self, count: int = 1, after: bool = False) -> int: 

1136 """ 

1137 Return the end of the current paragraph. (Relative cursor position.) 

1138 """ 

1139 

1140 def match_func(text: str) -> bool: 

1141 return not text or text.isspace() 

1142 

1143 line_index = self.find_next_matching_line(match_func=match_func, count=count) 

1144 

1145 if line_index: 

1146 add = 0 if after else 1 

1147 return max(0, self.get_cursor_down_position(count=line_index) - add) 

1148 else: 

1149 return len(self.text_after_cursor) 

1150 

1151 # Modifiers. 

1152 

1153 def insert_after(self, text: str) -> Document: 

1154 """ 

1155 Create a new document, with this text inserted after the buffer. 

1156 It keeps selection ranges and cursor position in sync. 

1157 """ 

1158 return Document( 

1159 text=self.text + text, 

1160 cursor_position=self.cursor_position, 

1161 selection=self.selection, 

1162 ) 

1163 

1164 def insert_before(self, text: str) -> Document: 

1165 """ 

1166 Create a new document, with this text inserted before the buffer. 

1167 It keeps selection ranges and cursor position in sync. 

1168 """ 

1169 selection_state = self.selection 

1170 

1171 if selection_state: 

1172 selection_state = SelectionState( 

1173 original_cursor_position=selection_state.original_cursor_position 

1174 + len(text), 

1175 type=selection_state.type, 

1176 ) 

1177 

1178 return Document( 

1179 text=text + self.text, 

1180 cursor_position=self.cursor_position + len(text), 

1181 selection=selection_state, 

1182 )