455 lines
17 KiB
Python
455 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from bisect import bisect_right
|
|
|
|
from rich.text import Text
|
|
|
|
from textual._cells import cell_len, cell_width_to_column_index
|
|
from textual._wrap import compute_wrap_offsets
|
|
from textual.document._document import DocumentBase, Location
|
|
from textual.expand_tabs import expand_tabs_inline, get_tab_widths
|
|
from textual.geometry import Offset, clamp
|
|
|
|
VerticalOffset = int
|
|
LineIndex = int
|
|
SectionOffset = int
|
|
|
|
|
|
class WrappedDocument:
|
|
"""A view into a Document which wraps the document at a certain
|
|
width and can be queried to retrieve lines from the *wrapped* version
|
|
of the document.
|
|
|
|
Allows for incremental updates, ensuring that we only re-wrap ranges of the document
|
|
that were influenced by edits.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
document: DocumentBase,
|
|
width: int = 0,
|
|
tab_width: int = 4,
|
|
) -> None:
|
|
"""Construct a WrappedDocument.
|
|
|
|
By default, a WrappedDocument is wrapped with width=0 (no wrapping).
|
|
To wrap the document, use the wrap() method.
|
|
|
|
Args:
|
|
document: The document to wrap.
|
|
width: The width to wrap at.
|
|
tab_width: The maximum width to consider for tab characters.
|
|
"""
|
|
self.document = document
|
|
"""The document wrapping is performed on."""
|
|
|
|
self._wrap_offsets: list[list[int]] = []
|
|
"""Maps line indices to the offsets within the line where wrapping
|
|
breaks should be added."""
|
|
|
|
self._tab_width_cache: list[list[int]] = []
|
|
"""Maps line indices to a list of tab widths. `[[2, 4]]` means that on line 0, the first
|
|
tab has width 2, and the second tab has width 4."""
|
|
|
|
self._offset_to_line_info: list[tuple[LineIndex, SectionOffset]] = []
|
|
"""Maps y_offsets (from the top of the document) to line_index and the offset
|
|
of the section within the line."""
|
|
|
|
self._line_index_to_offsets: list[list[VerticalOffset]] = []
|
|
"""Maps line indices to all the vertical offsets which correspond to that line."""
|
|
|
|
self._width: int = width
|
|
"""The width the document is currently wrapped at. This will correspond with
|
|
the value last passed into the `wrap` method."""
|
|
|
|
self._tab_width: int = tab_width
|
|
"""The maximum width to expand tabs to when considering their widths."""
|
|
|
|
self.wrap(width, tab_width)
|
|
|
|
@property
|
|
def wrapped(self) -> bool:
|
|
"""True if the content is wrapped. This is not the same as wrapping being "enabled".
|
|
For example, an empty document can have wrapping enabled, but no wrapping has actually
|
|
occurred.
|
|
|
|
In other words, this is True if the length of any line in the document is greater
|
|
than the available width."""
|
|
return len(self._line_index_to_offsets) == len(self._offset_to_line_info)
|
|
|
|
def wrap(self, width: int, tab_width: int | None = None) -> None:
|
|
"""Wrap and cache all lines in the document.
|
|
|
|
Args:
|
|
width: The width to wrap at. 0 for no wrapping.
|
|
tab_width: The maximum width to consider for tab characters. If None,
|
|
reuse the tab width.
|
|
"""
|
|
self._width = width
|
|
if tab_width:
|
|
self._tab_width = tab_width
|
|
|
|
# We're starting wrapping from scratch
|
|
new_wrap_offsets: list[list[int]] = []
|
|
offset_to_line_info: list[tuple[LineIndex, SectionOffset]] = []
|
|
line_index_to_offsets: list[list[VerticalOffset]] = []
|
|
line_tab_widths: list[list[int]] = []
|
|
|
|
append_wrap_offset = new_wrap_offsets.append
|
|
append_line_info = offset_to_line_info.append
|
|
append_line_offsets = line_index_to_offsets.append
|
|
append_line_tab_widths = line_tab_widths.append
|
|
|
|
current_offset = 0
|
|
tab_width = self._tab_width
|
|
for line_index, line in enumerate(self.document.lines):
|
|
tab_sections = get_tab_widths(line, tab_width)
|
|
wrap_offsets = (
|
|
compute_wrap_offsets(
|
|
line,
|
|
width,
|
|
tab_size=tab_width,
|
|
precomputed_tab_sections=tab_sections,
|
|
)
|
|
if width
|
|
else []
|
|
)
|
|
append_line_tab_widths([width for _, width in tab_sections])
|
|
append_wrap_offset(wrap_offsets)
|
|
append_line_offsets([])
|
|
for section_y_offset in range(len(wrap_offsets) + 1):
|
|
append_line_info((line_index, section_y_offset))
|
|
line_index_to_offsets[line_index].append(current_offset)
|
|
current_offset += 1
|
|
|
|
self._wrap_offsets = new_wrap_offsets
|
|
self._offset_to_line_info = offset_to_line_info
|
|
self._line_index_to_offsets = line_index_to_offsets
|
|
self._tab_width_cache = line_tab_widths
|
|
|
|
@property
|
|
def lines(self) -> list[list[str]]:
|
|
"""The lines of the wrapped version of the Document.
|
|
|
|
Each index in the returned list represents a line index in the raw
|
|
document. The list[str] at each index is the content of the raw document line
|
|
split into multiple lines via wrapping.
|
|
|
|
Note that this is expensive to compute and is not cached.
|
|
|
|
Returns:
|
|
A list of lines from the wrapped version of the document.
|
|
"""
|
|
wrapped_lines: list[list[str]] = []
|
|
append = wrapped_lines.append
|
|
for line_index, line in enumerate(self.document.lines):
|
|
divided = Text(line).divide(self._wrap_offsets[line_index])
|
|
append([section.plain for section in divided])
|
|
|
|
return wrapped_lines
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
"""The height of the wrapped document."""
|
|
return sum(len(offsets) + 1 for offsets in self._wrap_offsets)
|
|
|
|
def wrap_range(
|
|
self,
|
|
start: Location,
|
|
old_end: Location,
|
|
new_end: Location,
|
|
) -> None:
|
|
"""Incrementally recompute wrapping based on a performed edit.
|
|
|
|
This must be called *after* the source document has been edited.
|
|
|
|
Args:
|
|
start: The start location of the edit that was performed in document-space.
|
|
old_end: The old end location of the edit in document-space.
|
|
new_end: The new end location of the edit in document-space.
|
|
"""
|
|
start_line_index, _ = start
|
|
old_end_line_index, _ = old_end
|
|
new_end_line_index, _ = new_end
|
|
|
|
# Although end users should not be able to edit invalid ranges via a TextArea,
|
|
# programmers can pass whatever they wish to the edit API, so we need to clamp
|
|
# the edit ranges here to ensure we only attempt to update within the bounds
|
|
# of the wrapped document.
|
|
old_max_index = len(self._line_index_to_offsets) - 1
|
|
new_max_index = self.document.line_count - 1
|
|
|
|
start_line_index = clamp(
|
|
start_line_index, 0, min((old_max_index, new_max_index))
|
|
)
|
|
old_end_line_index = clamp(old_end_line_index, 0, old_max_index)
|
|
new_end_line_index = clamp(new_end_line_index, 0, new_max_index)
|
|
|
|
top_line_index, old_bottom_line_index = sorted(
|
|
(start_line_index, old_end_line_index)
|
|
)
|
|
new_bottom_line_index = max((start_line_index, new_end_line_index))
|
|
|
|
top_y_offset = self._line_index_to_offsets[top_line_index][0]
|
|
old_bottom_y_offset = self._line_index_to_offsets[old_bottom_line_index][-1]
|
|
|
|
# Get the new range of the edit from top to bottom.
|
|
new_lines = self.document.lines[top_line_index : new_bottom_line_index + 1]
|
|
|
|
new_wrap_offsets: list[list[int]] = []
|
|
new_line_index_to_offsets: list[list[VerticalOffset]] = []
|
|
new_offset_to_line_info: list[tuple[LineIndex, SectionOffset]] = []
|
|
new_tab_widths: list[list[int]] = []
|
|
|
|
append_wrap_offsets = new_wrap_offsets.append
|
|
append_tab_widths = new_tab_widths.append
|
|
|
|
width = self._width
|
|
tab_width = self._tab_width
|
|
|
|
# Add the new offsets between the top and new bottom (the new post-edit offsets)
|
|
current_y_offset = top_y_offset
|
|
for line_index, line in enumerate(new_lines, top_line_index):
|
|
tab_sections = get_tab_widths(line, tab_width)
|
|
wrap_offsets = (
|
|
compute_wrap_offsets(
|
|
line, width, tab_width, precomputed_tab_sections=tab_sections
|
|
)
|
|
if width
|
|
else []
|
|
)
|
|
append_tab_widths([width for _, width in tab_sections])
|
|
append_wrap_offsets(wrap_offsets)
|
|
|
|
# Collect up the new y offsets for this document line
|
|
y_offsets_for_line: list[int] = []
|
|
for section_offset in range(len(wrap_offsets) + 1):
|
|
y_offsets_for_line.append(current_y_offset)
|
|
new_offset_to_line_info.append((line_index, section_offset))
|
|
current_y_offset += 1
|
|
|
|
# Save the new y offsets for this line
|
|
new_line_index_to_offsets.append(y_offsets_for_line)
|
|
|
|
# Replace the range start -> old with the new wrapped lines
|
|
self._offset_to_line_info[top_y_offset : old_bottom_y_offset + 1] = (
|
|
new_offset_to_line_info
|
|
)
|
|
|
|
self._line_index_to_offsets[top_line_index : old_bottom_line_index + 1] = (
|
|
new_line_index_to_offsets
|
|
)
|
|
|
|
self._tab_width_cache[top_line_index : old_bottom_line_index + 1] = (
|
|
new_tab_widths
|
|
)
|
|
|
|
# How much did the edit/rewrap alter the offsets?
|
|
old_height = old_bottom_y_offset - top_y_offset + 1
|
|
new_height = len(new_offset_to_line_info)
|
|
|
|
offset_shift = new_height - old_height
|
|
line_shift = new_bottom_line_index - old_bottom_line_index
|
|
|
|
# Update the line info at all offsets below the edit region.
|
|
if line_shift:
|
|
for y_offset in range(
|
|
top_y_offset + new_height, len(self._offset_to_line_info)
|
|
):
|
|
old_line_index, section_offset = self._offset_to_line_info[y_offset]
|
|
new_line_index = old_line_index + line_shift
|
|
new_line_info = (new_line_index, section_offset)
|
|
self._offset_to_line_info[y_offset] = new_line_info
|
|
|
|
# Update the offsets at all lines below the edit region
|
|
if offset_shift:
|
|
for line_index in range(
|
|
top_line_index + len(new_lines), len(self._line_index_to_offsets)
|
|
):
|
|
old_offsets = self._line_index_to_offsets[line_index]
|
|
new_offsets = [offset + offset_shift for offset in old_offsets]
|
|
self._line_index_to_offsets[line_index] = new_offsets
|
|
|
|
self._wrap_offsets[top_line_index : old_bottom_line_index + 1] = (
|
|
new_wrap_offsets
|
|
)
|
|
|
|
def offset_to_location(self, offset: Offset) -> Location:
|
|
"""Given an offset within the wrapped/visual display of the document,
|
|
return the corresponding location in the document.
|
|
|
|
Args:
|
|
offset: The y-offset within the document.
|
|
|
|
Raises:
|
|
ValueError: When the given offset does not correspond to a line
|
|
in the document.
|
|
|
|
Returns:
|
|
The Location in the document corresponding to the given offset.
|
|
"""
|
|
x, y = offset
|
|
x = max(0, x)
|
|
y = max(0, y)
|
|
|
|
if not self._width:
|
|
# No wrapping, so we directly map offset to location and clamp.
|
|
line_index = min(y, len(self._wrap_offsets) - 1)
|
|
column_index = cell_width_to_column_index(
|
|
self.document.get_line(line_index), x, self._tab_width
|
|
)
|
|
return line_index, column_index
|
|
|
|
# Find the line corresponding to the given y offset in the wrapped document.
|
|
get_target_document_column = self.get_target_document_column
|
|
|
|
try:
|
|
offset_data = self._offset_to_line_info[y]
|
|
except IndexError:
|
|
# y-offset is too large
|
|
offset_data = self._offset_to_line_info[-1]
|
|
|
|
if offset_data is not None:
|
|
line_index, section_y = offset_data
|
|
location = line_index, get_target_document_column(
|
|
line_index,
|
|
x,
|
|
section_y,
|
|
)
|
|
else:
|
|
location = len(self._wrap_offsets) - 1, get_target_document_column(
|
|
-1, x, -1
|
|
)
|
|
|
|
# Offset doesn't match any line => land on bottom wrapped line
|
|
return location
|
|
|
|
def location_to_offset(self, location: Location) -> Offset:
|
|
"""
|
|
Convert a location in the document to an offset within the wrapped/visual display of the document.
|
|
|
|
Args:
|
|
location: The location in the document.
|
|
|
|
Returns:
|
|
The Offset in the document's visual display corresponding to the given location.
|
|
"""
|
|
line_index, column_index = location
|
|
|
|
# Clamp the line index to the bounds of the document
|
|
line_index = clamp(line_index, 0, len(self._line_index_to_offsets))
|
|
|
|
# Find the section index of this location, so that we know which y_offset to use
|
|
wrap_offsets = self.get_offsets(line_index)
|
|
section_start_columns = [0, *wrap_offsets]
|
|
section_index = bisect_right(wrap_offsets, column_index)
|
|
|
|
# Get the y-offsets corresponding to this line index
|
|
y_offsets = self._line_index_to_offsets[line_index]
|
|
section_column_index = column_index - section_start_columns[section_index]
|
|
|
|
section = self.get_sections(line_index)[section_index]
|
|
x_offset = cell_len(
|
|
expand_tabs_inline(section[:section_column_index], self._tab_width)
|
|
)
|
|
|
|
return Offset(x_offset, y_offsets[section_index])
|
|
|
|
def get_target_document_column(
|
|
self,
|
|
line_index: int,
|
|
x_offset: int,
|
|
y_offset: int,
|
|
) -> int:
|
|
"""Given a line index and the offsets within the wrapped version of that
|
|
line, return the corresponding column index in the raw document.
|
|
|
|
Args:
|
|
line_index: The index of the line in the document.
|
|
x_offset: The x-offset within the wrapped line.
|
|
y_offset: The y-offset within the wrapped line (supports negative indexing).
|
|
|
|
Returns:
|
|
The column index corresponding to the line index and y offset.
|
|
"""
|
|
|
|
# We've found the relevant line, now find the character by
|
|
# looking at the character corresponding to the offset width.
|
|
sections = self.get_sections(line_index)
|
|
|
|
# wrapped_section is the text that appears on a single y_offset within
|
|
# the TextArea. It's a potentially wrapped portion of a larger line from
|
|
# the original document.
|
|
target_section = sections[y_offset]
|
|
|
|
# Add the offsets from the wrapped sections above this one (from the same raw
|
|
# document line)
|
|
target_section_start = sum(
|
|
len(wrapped_section) for wrapped_section in sections[:y_offset]
|
|
)
|
|
|
|
# Get the column index within this wrapped section of the line
|
|
target_column_index = target_section_start + cell_width_to_column_index(
|
|
target_section, x_offset, self._tab_width
|
|
)
|
|
|
|
# If we're on the final section of a line, the cursor can legally rest beyond
|
|
# the end by a single cell. Otherwise, we'll need to ensure that we're
|
|
# keeping the cursor within the bounds of the target section.
|
|
if y_offset != len(sections) - 1 and y_offset != -1:
|
|
target_column_index = min(
|
|
target_column_index, target_section_start + len(target_section) - 1
|
|
)
|
|
|
|
return target_column_index
|
|
|
|
def get_sections(self, line_index: int) -> list[str]:
|
|
"""Return the sections for the given line index.
|
|
|
|
When wrapping is enabled, a single line in the document can visually span
|
|
multiple lines. The list returned represents that visually (each string in
|
|
the list represents a single section (y-offset) after wrapping happens).
|
|
|
|
Args:
|
|
line_index: The index of the line to get sections for.
|
|
|
|
Returns:
|
|
The wrapped line as a list of strings.
|
|
"""
|
|
line_offsets = self._wrap_offsets[line_index]
|
|
wrapped_lines = Text(self.document[line_index], end="").divide(line_offsets)
|
|
return [line.plain for line in wrapped_lines]
|
|
|
|
def get_offsets(self, line_index: int) -> list[int]:
|
|
"""Given a line index, get the offsets within that line where wrapping
|
|
should occur for the current document.
|
|
|
|
Args:
|
|
line_index: The index of the line within the document.
|
|
|
|
Raises:
|
|
ValueError: When `line_index` is out of bounds.
|
|
|
|
Returns:
|
|
The offsets within the line where wrapping should occur.
|
|
"""
|
|
wrap_offsets = self._wrap_offsets
|
|
out_of_bounds = line_index < 0 or line_index >= len(wrap_offsets)
|
|
if out_of_bounds:
|
|
raise ValueError(
|
|
f"The document line index {line_index!r} is out of bounds. "
|
|
f"The document contains {len(wrap_offsets)!r} lines."
|
|
)
|
|
return wrap_offsets[line_index]
|
|
|
|
def get_tab_widths(self, line_index: int) -> list[int]:
|
|
"""Return a list of the tab widths for the given line index.
|
|
|
|
Args:
|
|
line_index: The index of the line in the document.
|
|
|
|
Returns:
|
|
An ordered list of the expanded width of the tabs in the line.
|
|
"""
|
|
return self._tab_width_cache[line_index]
|