| | """ |
| | The `Document` that implements all the text operations/querying. |
| | """ |
| |
|
| | from __future__ import annotations |
| |
|
| | import bisect |
| | import re |
| | import string |
| | import weakref |
| | from typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast |
| |
|
| | from .clipboard import ClipboardData |
| | from .filters import vi_mode |
| | from .selection import PasteMode, SelectionState, SelectionType |
| |
|
| | __all__ = [ |
| | "Document", |
| | ] |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | _FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") |
| | _FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") |
| | _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( |
| | r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)" |
| | ) |
| |
|
| | |
| | |
| | _FIND_BIG_WORD_RE = re.compile(r"([^\s]+)") |
| | _FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)") |
| | _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)") |
| |
|
| | |
| | |
| | |
| | |
| | _text_to_document_cache: dict[str, _DocumentCache] = cast( |
| | Dict[str, "_DocumentCache"], |
| | weakref.WeakValueDictionary(), |
| | ) |
| |
|
| |
|
| | class _ImmutableLineList(List[str]): |
| | """ |
| | Some protection for our 'lines' list, which is assumed to be immutable in the cache. |
| | (Useful for detecting obvious bugs.) |
| | """ |
| |
|
| | def _error(self, *a: object, **kw: object) -> NoReturn: |
| | raise NotImplementedError("Attempt to modify an immutable list.") |
| |
|
| | __setitem__ = _error |
| | append = _error |
| | clear = _error |
| | extend = _error |
| | insert = _error |
| | pop = _error |
| | remove = _error |
| | reverse = _error |
| | sort = _error |
| |
|
| |
|
| | class _DocumentCache: |
| | def __init__(self) -> None: |
| | |
| | self.lines: _ImmutableLineList | None = None |
| |
|
| | |
| | self.line_indexes: list[int] | None = None |
| |
|
| |
|
| | class Document: |
| | """ |
| | This is a immutable class around the text and cursor position, and contains |
| | methods for querying this data, e.g. to give the text before the cursor. |
| | |
| | This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer` |
| | object, and accessed as the `document` property of that class. |
| | |
| | :param text: string |
| | :param cursor_position: int |
| | :param selection: :class:`.SelectionState` |
| | """ |
| |
|
| | __slots__ = ("_text", "_cursor_position", "_selection", "_cache") |
| |
|
| | def __init__( |
| | self, |
| | text: str = "", |
| | cursor_position: int | None = None, |
| | selection: SelectionState | None = None, |
| | ) -> None: |
| | |
| | |
| | assert cursor_position is None or cursor_position <= len(text), AssertionError( |
| | f"cursor_position={cursor_position!r}, len_text={len(text)!r}" |
| | ) |
| |
|
| | |
| | |
| | |
| | if cursor_position is None: |
| | cursor_position = len(text) |
| |
|
| | |
| | |
| | |
| | self._text = text |
| | self._cursor_position = cursor_position |
| | self._selection = selection |
| |
|
| | |
| | |
| | try: |
| | self._cache = _text_to_document_cache[self.text] |
| | except KeyError: |
| | self._cache = _DocumentCache() |
| | _text_to_document_cache[self.text] = self._cache |
| |
|
| | |
| | |
| | |
| | |
| | |
| |
|
| | def __repr__(self) -> str: |
| | return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})" |
| |
|
| | def __eq__(self, other: object) -> bool: |
| | if not isinstance(other, Document): |
| | return False |
| |
|
| | return ( |
| | self.text == other.text |
| | and self.cursor_position == other.cursor_position |
| | and self.selection == other.selection |
| | ) |
| |
|
| | @property |
| | def text(self) -> str: |
| | "The document text." |
| | return self._text |
| |
|
| | @property |
| | def cursor_position(self) -> int: |
| | "The document cursor position." |
| | return self._cursor_position |
| |
|
| | @property |
| | def selection(self) -> SelectionState | None: |
| | ":class:`.SelectionState` object." |
| | return self._selection |
| |
|
| | @property |
| | def current_char(self) -> str: |
| | """Return character under cursor or an empty string.""" |
| | return self._get_char_relative_to_cursor(0) or "" |
| |
|
| | @property |
| | def char_before_cursor(self) -> str: |
| | """Return character before the cursor or an empty string.""" |
| | return self._get_char_relative_to_cursor(-1) or "" |
| |
|
| | @property |
| | def text_before_cursor(self) -> str: |
| | return self.text[: self.cursor_position :] |
| |
|
| | @property |
| | def text_after_cursor(self) -> str: |
| | return self.text[self.cursor_position :] |
| |
|
| | @property |
| | def current_line_before_cursor(self) -> str: |
| | """Text from the start of the line until the cursor.""" |
| | _, _, text = self.text_before_cursor.rpartition("\n") |
| | return text |
| |
|
| | @property |
| | def current_line_after_cursor(self) -> str: |
| | """Text from the cursor until the end of the line.""" |
| | text, _, _ = self.text_after_cursor.partition("\n") |
| | return text |
| |
|
| | @property |
| | def lines(self) -> list[str]: |
| | """ |
| | Array of all the lines. |
| | """ |
| | |
| | if self._cache.lines is None: |
| | self._cache.lines = _ImmutableLineList(self.text.split("\n")) |
| |
|
| | return self._cache.lines |
| |
|
| | @property |
| | def _line_start_indexes(self) -> list[int]: |
| | """ |
| | Array pointing to the start indexes of all the lines. |
| | """ |
| | |
| | |
| | if self._cache.line_indexes is None: |
| | |
| | line_lengths = map(len, self.lines) |
| |
|
| | |
| | indexes = [0] |
| | append = indexes.append |
| | pos = 0 |
| |
|
| | for line_length in line_lengths: |
| | pos += line_length + 1 |
| | append(pos) |
| |
|
| | |
| | if len(indexes) > 1: |
| | indexes.pop() |
| |
|
| | self._cache.line_indexes = indexes |
| |
|
| | return self._cache.line_indexes |
| |
|
| | @property |
| | def lines_from_current(self) -> list[str]: |
| | """ |
| | Array of the lines starting from the current line, until the last line. |
| | """ |
| | return self.lines[self.cursor_position_row :] |
| |
|
| | @property |
| | def line_count(self) -> int: |
| | r"""Return the number of lines in this document. If the document ends |
| | with a trailing \n, that counts as the beginning of a new line.""" |
| | return len(self.lines) |
| |
|
| | @property |
| | def current_line(self) -> str: |
| | """Return the text on the line where the cursor is. (when the input |
| | consists of just one line, it equals `text`.""" |
| | return self.current_line_before_cursor + self.current_line_after_cursor |
| |
|
| | @property |
| | def leading_whitespace_in_current_line(self) -> str: |
| | """The leading whitespace in the left margin of the current line.""" |
| | current_line = self.current_line |
| | length = len(current_line) - len(current_line.lstrip()) |
| | return current_line[:length] |
| |
|
| | def _get_char_relative_to_cursor(self, offset: int = 0) -> str: |
| | """ |
| | Return character relative to cursor position, or empty string |
| | """ |
| | try: |
| | return self.text[self.cursor_position + offset] |
| | except IndexError: |
| | return "" |
| |
|
| | @property |
| | def on_first_line(self) -> bool: |
| | """ |
| | True when we are at the first line. |
| | """ |
| | return self.cursor_position_row == 0 |
| |
|
| | @property |
| | def on_last_line(self) -> bool: |
| | """ |
| | True when we are at the last line. |
| | """ |
| | return self.cursor_position_row == self.line_count - 1 |
| |
|
| | @property |
| | def cursor_position_row(self) -> int: |
| | """ |
| | Current row. (0-based.) |
| | """ |
| | row, _ = self._find_line_start_index(self.cursor_position) |
| | return row |
| |
|
| | @property |
| | def cursor_position_col(self) -> int: |
| | """ |
| | Current column. (0-based.) |
| | """ |
| | |
| | |
| | |
| | _, line_start_index = self._find_line_start_index(self.cursor_position) |
| | return self.cursor_position - line_start_index |
| |
|
| | def _find_line_start_index(self, index: int) -> tuple[int, int]: |
| | """ |
| | For the index of a character at a certain line, calculate the index of |
| | the first character on that line. |
| | |
| | Return (row, index) tuple. |
| | """ |
| | indexes = self._line_start_indexes |
| |
|
| | pos = bisect.bisect_right(indexes, index) - 1 |
| | return pos, indexes[pos] |
| |
|
| | def translate_index_to_position(self, index: int) -> tuple[int, int]: |
| | """ |
| | Given an index for the text, return the corresponding (row, col) tuple. |
| | (0-based. Returns (0, 0) for index=0.) |
| | """ |
| | |
| | row, row_index = self._find_line_start_index(index) |
| | col = index - row_index |
| |
|
| | return row, col |
| |
|
| | def translate_row_col_to_index(self, row: int, col: int) -> int: |
| | """ |
| | Given a (row, col) tuple, return the corresponding index. |
| | (Row and col params are 0-based.) |
| | |
| | Negative row/col values are turned into zero. |
| | """ |
| | try: |
| | result = self._line_start_indexes[row] |
| | line = self.lines[row] |
| | except IndexError: |
| | if row < 0: |
| | result = self._line_start_indexes[0] |
| | line = self.lines[0] |
| | else: |
| | result = self._line_start_indexes[-1] |
| | line = self.lines[-1] |
| |
|
| | result += max(0, min(col, len(line))) |
| |
|
| | |
| | |
| | result = max(0, min(result, len(self.text))) |
| | return result |
| |
|
| | @property |
| | def is_cursor_at_the_end(self) -> bool: |
| | """True when the cursor is at the end of the text.""" |
| | return self.cursor_position == len(self.text) |
| |
|
| | @property |
| | def is_cursor_at_the_end_of_line(self) -> bool: |
| | """True when the cursor is at the end of this line.""" |
| | return self.current_char in ("\n", "") |
| |
|
| | def has_match_at_current_position(self, sub: str) -> bool: |
| | """ |
| | `True` when this substring is found at the cursor position. |
| | """ |
| | return self.text.find(sub, self.cursor_position) == self.cursor_position |
| |
|
| | def find( |
| | self, |
| | sub: str, |
| | in_current_line: bool = False, |
| | include_current_position: bool = False, |
| | ignore_case: bool = False, |
| | count: int = 1, |
| | ) -> int | None: |
| | """ |
| | Find `text` after the cursor, return position relative to the cursor |
| | position. Return `None` if nothing was found. |
| | |
| | :param count: Find the n-th occurrence. |
| | """ |
| | assert isinstance(ignore_case, bool) |
| |
|
| | if in_current_line: |
| | text = self.current_line_after_cursor |
| | else: |
| | text = self.text_after_cursor |
| |
|
| | if not include_current_position: |
| | if len(text) == 0: |
| | return None |
| | else: |
| | text = text[1:] |
| |
|
| | flags = re.IGNORECASE if ignore_case else 0 |
| | iterator = re.finditer(re.escape(sub), text, flags) |
| |
|
| | try: |
| | for i, match in enumerate(iterator): |
| | if i + 1 == count: |
| | if include_current_position: |
| | return match.start(0) |
| | else: |
| | return match.start(0) + 1 |
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def find_all(self, sub: str, ignore_case: bool = False) -> list[int]: |
| | """ |
| | Find all occurrences of the substring. Return a list of absolute |
| | positions in the document. |
| | """ |
| | flags = re.IGNORECASE if ignore_case else 0 |
| | return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)] |
| |
|
| | def find_backwards( |
| | self, |
| | sub: str, |
| | in_current_line: bool = False, |
| | ignore_case: bool = False, |
| | count: int = 1, |
| | ) -> int | None: |
| | """ |
| | Find `text` before the cursor, return position relative to the cursor |
| | position. Return `None` if nothing was found. |
| | |
| | :param count: Find the n-th occurrence. |
| | """ |
| | if in_current_line: |
| | before_cursor = self.current_line_before_cursor[::-1] |
| | else: |
| | before_cursor = self.text_before_cursor[::-1] |
| |
|
| | flags = re.IGNORECASE if ignore_case else 0 |
| | iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags) |
| |
|
| | try: |
| | for i, match in enumerate(iterator): |
| | if i + 1 == count: |
| | return -match.start(0) - len(sub) |
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def get_word_before_cursor( |
| | self, WORD: bool = False, pattern: Pattern[str] | None = None |
| | ) -> str: |
| | """ |
| | Give the word before the cursor. |
| | If we have whitespace before the cursor this returns an empty string. |
| | |
| | :param pattern: (None or compiled regex). When given, use this regex |
| | pattern. |
| | """ |
| | if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern): |
| | |
| | return "" |
| |
|
| | text_before_cursor = self.text_before_cursor |
| | start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0 |
| |
|
| | return text_before_cursor[len(text_before_cursor) + start :] |
| |
|
| | def _is_word_before_cursor_complete( |
| | self, WORD: bool = False, pattern: Pattern[str] | None = None |
| | ) -> bool: |
| | if pattern: |
| | return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None |
| | else: |
| | return ( |
| | self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace() |
| | ) |
| |
|
| | def find_start_of_previous_word( |
| | self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None |
| | ) -> int | None: |
| | """ |
| | Return an index relative to the cursor position pointing to the start |
| | of the previous word. Return `None` if nothing was found. |
| | |
| | :param pattern: (None or compiled regex). When given, use this regex |
| | pattern. |
| | """ |
| | assert not (WORD and pattern) |
| |
|
| | |
| | |
| | text_before_cursor = self.text_before_cursor[::-1] |
| |
|
| | if pattern: |
| | regex = pattern |
| | elif WORD: |
| | regex = _FIND_BIG_WORD_RE |
| | else: |
| | regex = _FIND_WORD_RE |
| |
|
| | iterator = regex.finditer(text_before_cursor) |
| |
|
| | try: |
| | for i, match in enumerate(iterator): |
| | if i + 1 == count: |
| | return -match.end(0) |
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def find_boundaries_of_current_word( |
| | self, |
| | WORD: bool = False, |
| | include_leading_whitespace: bool = False, |
| | include_trailing_whitespace: bool = False, |
| | ) -> tuple[int, int]: |
| | """ |
| | Return the relative boundaries (startpos, endpos) of the current word under the |
| | cursor. (This is at the current line, because line boundaries obviously |
| | don't belong to any word.) |
| | If not on a word, this returns (0,0) |
| | """ |
| | text_before_cursor = self.current_line_before_cursor[::-1] |
| | text_after_cursor = self.current_line_after_cursor |
| |
|
| | def get_regex(include_whitespace: bool) -> Pattern[str]: |
| | return { |
| | (False, False): _FIND_CURRENT_WORD_RE, |
| | (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, |
| | (True, False): _FIND_CURRENT_BIG_WORD_RE, |
| | (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, |
| | }[(WORD, include_whitespace)] |
| |
|
| | match_before = get_regex(include_leading_whitespace).search(text_before_cursor) |
| | match_after = get_regex(include_trailing_whitespace).search(text_after_cursor) |
| |
|
| | |
| | |
| | |
| | |
| | if not WORD and match_before and match_after: |
| | c1 = self.text[self.cursor_position - 1] |
| | c2 = self.text[self.cursor_position] |
| | alphabet = string.ascii_letters + "0123456789_" |
| |
|
| | if (c1 in alphabet) != (c2 in alphabet): |
| | match_before = None |
| |
|
| | return ( |
| | -match_before.end(1) if match_before else 0, |
| | match_after.end(1) if match_after else 0, |
| | ) |
| |
|
| | def get_word_under_cursor(self, WORD: bool = False) -> str: |
| | """ |
| | Return the word, currently below the cursor. |
| | This returns an empty string when the cursor is on a whitespace region. |
| | """ |
| | start, end = self.find_boundaries_of_current_word(WORD=WORD) |
| | return self.text[self.cursor_position + start : self.cursor_position + end] |
| |
|
| | def find_next_word_beginning( |
| | self, count: int = 1, WORD: bool = False |
| | ) -> int | None: |
| | """ |
| | Return an index relative to the cursor position pointing to the start |
| | of the next word. Return `None` if nothing was found. |
| | """ |
| | if count < 0: |
| | return self.find_previous_word_beginning(count=-count, WORD=WORD) |
| |
|
| | regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE |
| | iterator = regex.finditer(self.text_after_cursor) |
| |
|
| | try: |
| | for i, match in enumerate(iterator): |
| | |
| | if i == 0 and match.start(1) == 0: |
| | count += 1 |
| |
|
| | if i + 1 == count: |
| | return match.start(1) |
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def find_next_word_ending( |
| | self, include_current_position: bool = False, count: int = 1, WORD: bool = False |
| | ) -> int | None: |
| | """ |
| | Return an index relative to the cursor position pointing to the end |
| | of the next word. Return `None` if nothing was found. |
| | """ |
| | if count < 0: |
| | return self.find_previous_word_ending(count=-count, WORD=WORD) |
| |
|
| | if include_current_position: |
| | text = self.text_after_cursor |
| | else: |
| | text = self.text_after_cursor[1:] |
| |
|
| | regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE |
| | iterable = regex.finditer(text) |
| |
|
| | try: |
| | for i, match in enumerate(iterable): |
| | if i + 1 == count: |
| | value = match.end(1) |
| |
|
| | if include_current_position: |
| | return value |
| | else: |
| | return value + 1 |
| |
|
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def find_previous_word_beginning( |
| | self, count: int = 1, WORD: bool = False |
| | ) -> int | None: |
| | """ |
| | Return an index relative to the cursor position pointing to the start |
| | of the previous word. Return `None` if nothing was found. |
| | """ |
| | if count < 0: |
| | return self.find_next_word_beginning(count=-count, WORD=WORD) |
| |
|
| | regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE |
| | iterator = regex.finditer(self.text_before_cursor[::-1]) |
| |
|
| | try: |
| | for i, match in enumerate(iterator): |
| | if i + 1 == count: |
| | return -match.end(1) |
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def find_previous_word_ending( |
| | self, count: int = 1, WORD: bool = False |
| | ) -> int | None: |
| | """ |
| | Return an index relative to the cursor position pointing to the end |
| | of the previous word. Return `None` if nothing was found. |
| | """ |
| | if count < 0: |
| | return self.find_next_word_ending(count=-count, WORD=WORD) |
| |
|
| | text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1] |
| |
|
| | regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE |
| | iterator = regex.finditer(text_before_cursor) |
| |
|
| | try: |
| | for i, match in enumerate(iterator): |
| | |
| | if i == 0 and match.start(1) == 0: |
| | count += 1 |
| |
|
| | if i + 1 == count: |
| | return -match.start(1) + 1 |
| | except StopIteration: |
| | pass |
| | return None |
| |
|
| | def find_next_matching_line( |
| | self, match_func: Callable[[str], bool], count: int = 1 |
| | ) -> int | None: |
| | """ |
| | Look downwards for empty lines. |
| | Return the line index, relative to the current line. |
| | """ |
| | result = None |
| |
|
| | for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]): |
| | if match_func(line): |
| | result = 1 + index |
| | count -= 1 |
| |
|
| | if count == 0: |
| | break |
| |
|
| | return result |
| |
|
| | def find_previous_matching_line( |
| | self, match_func: Callable[[str], bool], count: int = 1 |
| | ) -> int | None: |
| | """ |
| | Look upwards for empty lines. |
| | Return the line index, relative to the current line. |
| | """ |
| | result = None |
| |
|
| | for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]): |
| | if match_func(line): |
| | result = -1 - index |
| | count -= 1 |
| |
|
| | if count == 0: |
| | break |
| |
|
| | return result |
| |
|
| | def get_cursor_left_position(self, count: int = 1) -> int: |
| | """ |
| | Relative position for cursor left. |
| | """ |
| | if count < 0: |
| | return self.get_cursor_right_position(-count) |
| |
|
| | return -min(self.cursor_position_col, count) |
| |
|
| | def get_cursor_right_position(self, count: int = 1) -> int: |
| | """ |
| | Relative position for cursor_right. |
| | """ |
| | if count < 0: |
| | return self.get_cursor_left_position(-count) |
| |
|
| | return min(count, len(self.current_line_after_cursor)) |
| |
|
| | def get_cursor_up_position( |
| | self, count: int = 1, preferred_column: int | None = None |
| | ) -> int: |
| | """ |
| | Return the relative cursor position (character index) where we would be if the |
| | user pressed the arrow-up button. |
| | |
| | :param preferred_column: When given, go to this column instead of |
| | staying at the current column. |
| | """ |
| | assert count >= 1 |
| | column = ( |
| | self.cursor_position_col if preferred_column is None else preferred_column |
| | ) |
| |
|
| | return ( |
| | self.translate_row_col_to_index( |
| | max(0, self.cursor_position_row - count), column |
| | ) |
| | - self.cursor_position |
| | ) |
| |
|
| | def get_cursor_down_position( |
| | self, count: int = 1, preferred_column: int | None = None |
| | ) -> int: |
| | """ |
| | Return the relative cursor position (character index) where we would be if the |
| | user pressed the arrow-down button. |
| | |
| | :param preferred_column: When given, go to this column instead of |
| | staying at the current column. |
| | """ |
| | assert count >= 1 |
| | column = ( |
| | self.cursor_position_col if preferred_column is None else preferred_column |
| | ) |
| |
|
| | return ( |
| | self.translate_row_col_to_index(self.cursor_position_row + count, column) |
| | - self.cursor_position |
| | ) |
| |
|
| | def find_enclosing_bracket_right( |
| | self, left_ch: str, right_ch: str, end_pos: int | None = None |
| | ) -> int | None: |
| | """ |
| | Find the right bracket enclosing current position. Return the relative |
| | position to the cursor position. |
| | |
| | When `end_pos` is given, don't look past the position. |
| | """ |
| | if self.current_char == right_ch: |
| | return 0 |
| |
|
| | if end_pos is None: |
| | end_pos = len(self.text) |
| | else: |
| | end_pos = min(len(self.text), end_pos) |
| |
|
| | stack = 1 |
| |
|
| | |
| | for i in range(self.cursor_position + 1, end_pos): |
| | c = self.text[i] |
| |
|
| | if c == left_ch: |
| | stack += 1 |
| | elif c == right_ch: |
| | stack -= 1 |
| |
|
| | if stack == 0: |
| | return i - self.cursor_position |
| |
|
| | return None |
| |
|
| | def find_enclosing_bracket_left( |
| | self, left_ch: str, right_ch: str, start_pos: int | None = None |
| | ) -> int | None: |
| | """ |
| | Find the left bracket enclosing current position. Return the relative |
| | position to the cursor position. |
| | |
| | When `start_pos` is given, don't look past the position. |
| | """ |
| | if self.current_char == left_ch: |
| | return 0 |
| |
|
| | if start_pos is None: |
| | start_pos = 0 |
| | else: |
| | start_pos = max(0, start_pos) |
| |
|
| | stack = 1 |
| |
|
| | |
| | for i in range(self.cursor_position - 1, start_pos - 1, -1): |
| | c = self.text[i] |
| |
|
| | if c == right_ch: |
| | stack += 1 |
| | elif c == left_ch: |
| | stack -= 1 |
| |
|
| | if stack == 0: |
| | return i - self.cursor_position |
| |
|
| | return None |
| |
|
| | def find_matching_bracket_position( |
| | self, start_pos: int | None = None, end_pos: int | None = None |
| | ) -> int: |
| | """ |
| | Return relative cursor position of matching [, (, { or < bracket. |
| | |
| | When `start_pos` or `end_pos` are given. Don't look past the positions. |
| | """ |
| |
|
| | |
| | for pair in "()", "[]", "{}", "<>": |
| | A = pair[0] |
| | B = pair[1] |
| | if self.current_char == A: |
| | return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0 |
| | elif self.current_char == B: |
| | return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0 |
| |
|
| | return 0 |
| |
|
| | def get_start_of_document_position(self) -> int: |
| | """Relative position for the start of the document.""" |
| | return -self.cursor_position |
| |
|
| | def get_end_of_document_position(self) -> int: |
| | """Relative position for the end of the document.""" |
| | return len(self.text) - self.cursor_position |
| |
|
| | def get_start_of_line_position(self, after_whitespace: bool = False) -> int: |
| | """Relative position for the start of this line.""" |
| | if after_whitespace: |
| | current_line = self.current_line |
| | return ( |
| | len(current_line) |
| | - len(current_line.lstrip()) |
| | - self.cursor_position_col |
| | ) |
| | else: |
| | return -len(self.current_line_before_cursor) |
| |
|
| | def get_end_of_line_position(self) -> int: |
| | """Relative position for the end of this line.""" |
| | return len(self.current_line_after_cursor) |
| |
|
| | def last_non_blank_of_current_line_position(self) -> int: |
| | """ |
| | Relative position for the last non blank character of this line. |
| | """ |
| | return len(self.current_line.rstrip()) - self.cursor_position_col - 1 |
| |
|
| | def get_column_cursor_position(self, column: int) -> int: |
| | """ |
| | Return the relative cursor position for this column at the current |
| | line. (It will stay between the boundaries of the line in case of a |
| | larger number.) |
| | """ |
| | line_length = len(self.current_line) |
| | current_column = self.cursor_position_col |
| | column = max(0, min(line_length, column)) |
| |
|
| | return column - current_column |
| |
|
| | def selection_range( |
| | self, |
| | ) -> tuple[ |
| | int, int |
| | ]: |
| | """ |
| | Return (from, to) tuple of the selection. |
| | start and end position are included. |
| | |
| | This doesn't take the selection type into account. Use |
| | `selection_ranges` instead. |
| | """ |
| | if self.selection: |
| | from_, to = sorted( |
| | [self.cursor_position, self.selection.original_cursor_position] |
| | ) |
| | else: |
| | from_, to = self.cursor_position, self.cursor_position |
| |
|
| | return from_, to |
| |
|
| | def selection_ranges(self) -> Iterable[tuple[int, int]]: |
| | """ |
| | Return a list of `(from, to)` tuples for the selection or none if |
| | nothing was selected. The upper boundary is not included. |
| | |
| | This will yield several (from, to) tuples in case of a BLOCK selection. |
| | This will return zero ranges, like (8,8) for empty lines in a block |
| | selection. |
| | """ |
| | if self.selection: |
| | from_, to = sorted( |
| | [self.cursor_position, self.selection.original_cursor_position] |
| | ) |
| |
|
| | if self.selection.type == SelectionType.BLOCK: |
| | from_line, from_column = self.translate_index_to_position(from_) |
| | to_line, to_column = self.translate_index_to_position(to) |
| | from_column, to_column = sorted([from_column, to_column]) |
| | lines = self.lines |
| |
|
| | if vi_mode(): |
| | to_column += 1 |
| |
|
| | for l in range(from_line, to_line + 1): |
| | line_length = len(lines[l]) |
| |
|
| | if from_column <= line_length: |
| | yield ( |
| | self.translate_row_col_to_index(l, from_column), |
| | self.translate_row_col_to_index( |
| | l, min(line_length, to_column) |
| | ), |
| | ) |
| | else: |
| | |
| | if self.selection.type == SelectionType.LINES: |
| | from_ = max(0, self.text.rfind("\n", 0, from_) + 1) |
| |
|
| | if self.text.find("\n", to) >= 0: |
| | to = self.text.find("\n", to) |
| | else: |
| | to = len(self.text) - 1 |
| |
|
| | |
| | |
| | if vi_mode(): |
| | to += 1 |
| |
|
| | yield from_, to |
| |
|
| | def selection_range_at_line(self, row: int) -> tuple[int, int] | None: |
| | """ |
| | If the selection spans a portion of the given line, return a (from, to) tuple. |
| | |
| | The returned upper boundary is not included in the selection, so |
| | `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection. |
| | |
| | Returns None if the selection doesn't cover this line at all. |
| | """ |
| | if self.selection: |
| | line = self.lines[row] |
| |
|
| | row_start = self.translate_row_col_to_index(row, 0) |
| | row_end = self.translate_row_col_to_index(row, len(line)) |
| |
|
| | from_, to = sorted( |
| | [self.cursor_position, self.selection.original_cursor_position] |
| | ) |
| |
|
| | |
| | intersection_start = max(row_start, from_) |
| | intersection_end = min(row_end, to) |
| |
|
| | if intersection_start <= intersection_end: |
| | if self.selection.type == SelectionType.LINES: |
| | intersection_start = row_start |
| | intersection_end = row_end |
| |
|
| | elif self.selection.type == SelectionType.BLOCK: |
| | _, col1 = self.translate_index_to_position(from_) |
| | _, col2 = self.translate_index_to_position(to) |
| | col1, col2 = sorted([col1, col2]) |
| |
|
| | if col1 > len(line): |
| | return None |
| |
|
| | intersection_start = self.translate_row_col_to_index(row, col1) |
| | intersection_end = self.translate_row_col_to_index(row, col2) |
| |
|
| | _, from_column = self.translate_index_to_position(intersection_start) |
| | _, to_column = self.translate_index_to_position(intersection_end) |
| |
|
| | |
| | |
| | if vi_mode(): |
| | to_column += 1 |
| |
|
| | return from_column, to_column |
| | return None |
| |
|
| | def cut_selection(self) -> tuple[Document, ClipboardData]: |
| | """ |
| | Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the |
| | document represents the new document when the selection is cut, and the |
| | clipboard data, represents whatever has to be put on the clipboard. |
| | """ |
| | if self.selection: |
| | cut_parts = [] |
| | remaining_parts = [] |
| | new_cursor_position = self.cursor_position |
| |
|
| | last_to = 0 |
| | for from_, to in self.selection_ranges(): |
| | if last_to == 0: |
| | new_cursor_position = from_ |
| |
|
| | remaining_parts.append(self.text[last_to:from_]) |
| | cut_parts.append(self.text[from_:to]) |
| | last_to = to |
| |
|
| | remaining_parts.append(self.text[last_to:]) |
| |
|
| | cut_text = "\n".join(cut_parts) |
| | remaining_text = "".join(remaining_parts) |
| |
|
| | |
| | if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"): |
| | cut_text = cut_text[:-1] |
| |
|
| | return ( |
| | Document(text=remaining_text, cursor_position=new_cursor_position), |
| | ClipboardData(cut_text, self.selection.type), |
| | ) |
| | else: |
| | return self, ClipboardData("") |
| |
|
| | def paste_clipboard_data( |
| | self, |
| | data: ClipboardData, |
| | paste_mode: PasteMode = PasteMode.EMACS, |
| | count: int = 1, |
| | ) -> Document: |
| | """ |
| | Return a new :class:`.Document` instance which contains the result if |
| | we would paste this data at the current cursor position. |
| | |
| | :param paste_mode: Where to paste. (Before/after/emacs.) |
| | :param count: When >1, Paste multiple times. |
| | """ |
| | before = paste_mode == PasteMode.VI_BEFORE |
| | after = paste_mode == PasteMode.VI_AFTER |
| |
|
| | if data.type == SelectionType.CHARACTERS: |
| | if after: |
| | new_text = ( |
| | self.text[: self.cursor_position + 1] |
| | + data.text * count |
| | + self.text[self.cursor_position + 1 :] |
| | ) |
| | else: |
| | new_text = ( |
| | self.text_before_cursor + data.text * count + self.text_after_cursor |
| | ) |
| |
|
| | new_cursor_position = self.cursor_position + len(data.text) * count |
| | if before: |
| | new_cursor_position -= 1 |
| |
|
| | elif data.type == SelectionType.LINES: |
| | l = self.cursor_position_row |
| | if before: |
| | lines = self.lines[:l] + [data.text] * count + self.lines[l:] |
| | new_text = "\n".join(lines) |
| | new_cursor_position = len("".join(self.lines[:l])) + l |
| | else: |
| | lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :] |
| | new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1 |
| | new_text = "\n".join(lines) |
| |
|
| | elif data.type == SelectionType.BLOCK: |
| | lines = self.lines[:] |
| | start_line = self.cursor_position_row |
| | start_column = self.cursor_position_col + (0 if before else 1) |
| |
|
| | for i, line in enumerate(data.text.split("\n")): |
| | index = i + start_line |
| | if index >= len(lines): |
| | lines.append("") |
| |
|
| | lines[index] = lines[index].ljust(start_column) |
| | lines[index] = ( |
| | lines[index][:start_column] |
| | + line * count |
| | + lines[index][start_column:] |
| | ) |
| |
|
| | new_text = "\n".join(lines) |
| | new_cursor_position = self.cursor_position + (0 if before else 1) |
| |
|
| | return Document(text=new_text, cursor_position=new_cursor_position) |
| |
|
| | def empty_line_count_at_the_end(self) -> int: |
| | """ |
| | Return number of empty lines at the end of the document. |
| | """ |
| | count = 0 |
| | for line in self.lines[::-1]: |
| | if not line or line.isspace(): |
| | count += 1 |
| | else: |
| | break |
| |
|
| | return count |
| |
|
| | def start_of_paragraph(self, count: int = 1, before: bool = False) -> int: |
| | """ |
| | Return the start of the current paragraph. (Relative cursor position.) |
| | """ |
| |
|
| | def match_func(text: str) -> bool: |
| | return not text or text.isspace() |
| |
|
| | line_index = self.find_previous_matching_line( |
| | match_func=match_func, count=count |
| | ) |
| |
|
| | if line_index: |
| | add = 0 if before else 1 |
| | return min(0, self.get_cursor_up_position(count=-line_index) + add) |
| | else: |
| | return -self.cursor_position |
| |
|
| | def end_of_paragraph(self, count: int = 1, after: bool = False) -> int: |
| | """ |
| | Return the end of the current paragraph. (Relative cursor position.) |
| | """ |
| |
|
| | def match_func(text: str) -> bool: |
| | return not text or text.isspace() |
| |
|
| | line_index = self.find_next_matching_line(match_func=match_func, count=count) |
| |
|
| | if line_index: |
| | add = 0 if after else 1 |
| | return max(0, self.get_cursor_down_position(count=line_index) - add) |
| | else: |
| | return len(self.text_after_cursor) |
| |
|
| | |
| |
|
| | def insert_after(self, text: str) -> Document: |
| | """ |
| | Create a new document, with this text inserted after the buffer. |
| | It keeps selection ranges and cursor position in sync. |
| | """ |
| | return Document( |
| | text=self.text + text, |
| | cursor_position=self.cursor_position, |
| | selection=self.selection, |
| | ) |
| |
|
| | def insert_before(self, text: str) -> Document: |
| | """ |
| | Create a new document, with this text inserted before the buffer. |
| | It keeps selection ranges and cursor position in sync. |
| | """ |
| | selection_state = self.selection |
| |
|
| | if selection_state: |
| | selection_state = SelectionState( |
| | original_cursor_position=selection_state.original_cursor_position |
| | + len(text), |
| | type=selection_state.type, |
| | ) |
| |
|
| | return Document( |
| | text=text + self.text, |
| | cursor_position=self.cursor_position + len(text), |
| | selection=selection_state, |
| | ) |
| |
|