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