ai-station/.venv/lib/python3.12/site-packages/textual/document/_document.py

474 lines
15 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, NamedTuple, Tuple, overload
from typing_extensions import Literal, get_args
if TYPE_CHECKING:
from tree_sitter import Node, Query
from textual._cells import cell_len
from textual.geometry import Size
Newline = Literal["\r\n", "\n", "\r"]
"""The type representing valid line separators."""
VALID_NEWLINES = set(get_args(Newline))
"""The set of valid line separator strings."""
@dataclass
class EditResult:
"""Contains information about an edit that has occurred."""
end_location: Location
"""The new end Location after the edit is complete."""
replaced_text: str
"""The text that was replaced."""
@lru_cache(maxsize=1024)
def _utf8_encode(text: str) -> bytes:
"""Encode the input text as utf-8 bytes.
The returned encoded bytes may be retrieved from a cache.
Args:
text: The text to encode.
Returns:
The utf-8 bytes representing the input string.
"""
return text.encode("utf-8")
def _detect_newline_style(text: str) -> Newline:
"""Return the newline type used in this document.
Args:
text: The text to inspect.
Returns:
The Newline used in the file.
"""
if "\r\n" in text: # Windows newline
return "\r\n"
elif "\n" in text: # Unix/Linux/MacOS newline
return "\n"
elif "\r" in text: # Old MacOS newline
return "\r"
else:
return "\n" # Default to Unix style newline
class DocumentBase(ABC):
"""Describes the minimum functionality a Document implementation must
provide in order to be used by the TextArea widget."""
@abstractmethod
def replace_range(self, start: Location, end: Location, text: str) -> EditResult:
"""Replace the text at the given range.
Args:
start: A tuple (row, column) where the edit starts.
end: A tuple (row, column) where the edit ends.
text: The text to insert between start and end.
Returns:
The new end location after the edit is complete.
"""
@property
@abstractmethod
def text(self) -> str:
"""The text from the document as a string."""
@property
@abstractmethod
def newline(self) -> Newline:
"""Return the line separator used in the document."""
@property
@abstractmethod
def lines(self) -> list[str]:
"""Get the lines of the document as a list of strings.
The strings should *not* include newline characters. The newline
character used for the document can be retrieved via the newline
property.
"""
@abstractmethod
def get_line(self, index: int) -> str:
"""Returns the line with the given index from the document.
This is used in rendering lines, and will be called by the
TextArea for each line that is rendered.
Args:
index: The index of the line in the document.
Returns:
The str instance representing the line.
"""
@abstractmethod
def get_text_range(self, start: Location, end: Location) -> str:
"""Get the text that falls between the start and end locations.
Args:
start: The start location of the selection.
end: The end location of the selection.
Returns:
The text between start (inclusive) and end (exclusive).
"""
@abstractmethod
def get_size(self, indent_width: int) -> Size:
"""Get the size of the document.
The height is generally the number of lines, and the width
is generally the maximum cell length of all the lines.
Args:
indent_width: The width to use for tab characters.
Returns:
The Size of the document bounding box.
"""
def query_syntax_tree(
self,
query: "Query",
start_point: tuple[int, int] | None = None,
end_point: tuple[int, int] | None = None,
) -> dict[str, list["Node"]]:
"""Query the tree-sitter syntax tree.
The default implementation always returns an empty list.
To support querying in a subclass, this must be implemented.
Args:
query: The tree-sitter Query to perform.
start_point: The (row, column byte) to start the query at.
end_point: The (row, column byte) to end the query at.
Returns:
A dict mapping captured node names to lists of Nodes with that name.
"""
return {}
def prepare_query(self, query: str) -> "Query | None":
return None
@property
@abstractmethod
def line_count(self) -> int:
"""Returns the number of lines in the document."""
@property
@abstractmethod
def start(self) -> Location:
"""Returns the location of the start of the document (0, 0)."""
return (0, 0)
@property
@abstractmethod
def end(self) -> Location:
"""Returns the location of the end of the document."""
if TYPE_CHECKING:
@overload
def __getitem__(self, line_index: int) -> str: ...
@overload
def __getitem__(self, line_index: slice) -> list[str]: ...
@abstractmethod
def __getitem__(self, line_index: int | slice) -> str | list[str]:
"""Return the content of a line as a string, excluding newline characters.
Args:
line_index: The index or slice of the line(s) to retrieve.
Returns:
The line or list of lines requested.
"""
class Document(DocumentBase):
"""A document which can be opened in a TextArea."""
def __init__(self, text: str) -> None:
self._newline: Newline = _detect_newline_style(text)
"""The type of newline used in the text."""
self._lines: list[str] = text.splitlines(keepends=False)
"""The lines of the document, excluding newline characters.
If there's a newline at the end of the file, the final line is an empty string.
"""
if text.endswith(tuple(VALID_NEWLINES)) or not text:
self._lines.append("")
@property
def lines(self) -> list[str]:
"""Get the document as a list of strings, where each string represents a line.
Newline characters are not included in at the end of the strings.
The newline character used in this document can be found via the `Document.newline` property.
"""
return self._lines
@property
def text(self) -> str:
"""Get the text from the document."""
return self._newline.join(self._lines)
@property
def newline(self) -> Newline:
"""Get the Newline used in this document (e.g. '\r\n', '\n'. etc.)"""
return self._newline
def get_size(self, tab_width: int) -> Size:
"""The Size of the document, taking into account the tab rendering width.
Args:
tab_width: The width to use for tab indents.
Returns:
The size (width, height) of the document.
"""
lines = self._lines
cell_lengths = [cell_len(line.expandtabs(tab_width)) for line in lines]
max_cell_length = max(cell_lengths, default=0)
height = len(lines)
return Size(max_cell_length, height)
def replace_range(self, start: Location, end: Location, text: str) -> EditResult:
"""Replace text at the given range.
This is the only method by which a document may be updated.
Args:
start: A tuple (row, column) where the edit starts.
end: A tuple (row, column) where the edit ends.
text: The text to insert between start and end.
Returns:
The EditResult containing information about the completed
replace operation.
"""
top, bottom = sorted((start, end))
top_row, top_column = top
bottom_row, bottom_column = bottom
insert_lines = text.splitlines()
if text.endswith(tuple(VALID_NEWLINES)):
# Special case where a single newline character is inserted.
insert_lines.append("")
lines = self._lines
replaced_text = self.get_text_range(top, bottom)
if bottom_row >= len(lines):
after_selection = ""
else:
after_selection = lines[bottom_row][bottom_column:]
if top_row >= len(lines):
before_selection = ""
else:
before_selection = lines[top_row][:top_column]
if insert_lines:
insert_lines[0] = before_selection + insert_lines[0]
destination_column = len(insert_lines[-1])
insert_lines[-1] = insert_lines[-1] + after_selection
else:
destination_column = len(before_selection)
insert_lines = [before_selection + after_selection]
lines[top_row : bottom_row + 1] = insert_lines
destination_row = top_row + len(insert_lines) - 1
end_location = (destination_row, destination_column)
return EditResult(end_location, replaced_text)
def get_text_range(self, start: Location, end: Location) -> str:
"""Get the text that falls between the start and end locations.
Returns the text between `start` and `end`, including the appropriate
line separator character as specified by `Document._newline`. Note that
`_newline` is set automatically to the first line separator character
found in the document.
Args:
start: The start location of the selection.
end: The end location of the selection.
Returns:
The text between start (inclusive) and end (exclusive).
"""
if start == end:
return ""
top, bottom = sorted((start, end))
top_row, top_column = top
bottom_row, bottom_column = bottom
lines = self._lines
if top_row == bottom_row:
line = lines[top_row]
selected_text = line[top_column:bottom_column]
else:
start_line = lines[top_row]
end_line = lines[bottom_row] if bottom_row <= self.line_count - 1 else ""
selected_text = start_line[top_column:]
for row in range(top_row + 1, bottom_row):
selected_text += self._newline + lines[row]
if bottom_row < self.line_count:
selected_text += self._newline
selected_text += end_line[:bottom_column]
return selected_text
@property
def line_count(self) -> int:
"""Returns the number of lines in the document."""
return len(self._lines)
@property
def start(self) -> Location:
"""Returns the location of the start of the document (0, 0)."""
return super().start
@property
def end(self) -> Location:
"""Returns the location of the end of the document."""
last_line = self._lines[-1]
return (self.line_count - 1, len(last_line))
def get_index_from_location(self, location: Location) -> int:
"""Given a location, returns the index from the document's text.
Args:
location: The location in the document.
Returns:
The index in the document's text.
"""
row, column = location
index = row * len(self.newline) + column
for line_index in range(row):
index += len(self.get_line(line_index))
return index
def get_location_from_index(self, index: int) -> Location:
"""Given a codepoint index in the document's text, returns the corresponding location.
Args:
index: The index in the document's text.
Returns:
The corresponding location.
Raises:
ValueError: If the index is doesn't correspond to a location in the document.
"""
error_message = (
f"Index {index!r} does not correspond to a location in the document."
)
if index < 0 or index > len(self.text):
raise ValueError(error_message)
column_index = 0
newline_length = len(self.newline)
for line_index in range(self.line_count):
next_column_index = (
column_index + len(self.get_line(line_index)) + newline_length
)
if index < next_column_index:
return (line_index, index - column_index)
elif index == next_column_index:
return (line_index + 1, 0)
column_index = next_column_index
raise ValueError(error_message)
def get_line(self, index: int) -> str:
"""Returns the line with the given index from the document.
Args:
index: The index of the line in the document.
Returns:
The string representing the line.
"""
line_string = self[index]
return line_string
@overload
def __getitem__(self, line_index: int) -> str: ...
@overload
def __getitem__(self, line_index: slice) -> list[str]: ...
def __getitem__(self, line_index: int | slice) -> str | list[str]:
"""Return the content of a line as a string, excluding newline characters.
Args:
line_index: The index or slice of the line(s) to retrieve.
Returns:
The line or list of lines requested.
"""
return self._lines[line_index]
Location = Tuple[int, int]
"""A location (row, column) within the document. Indexing starts at 0."""
class Selection(NamedTuple):
"""A range of characters within a document from a start point to the end point.
The location of the cursor is always considered to be the `end` point of the selection.
The selection is inclusive of the minimum point and exclusive of the maximum point.
"""
start: Location = (0, 0)
"""The start location of the selection.
If you were to click and drag a selection inside a text-editor, this is where you *started* dragging.
"""
end: Location = (0, 0)
"""The end location of the selection.
If you were to click and drag a selection inside a text-editor, this is where you *finished* dragging.
"""
@classmethod
def cursor(cls, location: Location) -> "Selection":
"""Create a Selection with the same start and end point - a "cursor".
Args:
location: The location to create the zero-width Selection.
"""
return cls(location, location)
@property
def is_empty(self) -> bool:
"""Return True if the selection has 0 width, i.e. it's just a cursor."""
start, end = self
return start == end
def contains_line(self, y: int) -> bool:
"""Check if the given line is within the selection."""
top, bottom = sorted((self.start[0], self.end[0]))
return y >= top and y <= bottom