Skip to content

Commit fd8d381

Browse files
committed
Appear to have working render-based diff method.
Now capturing a pre-rendered version of the TextArea window and using changes to the pre-rendered version to generate change regions.
1 parent 2769720 commit fd8d381

File tree

38 files changed

+4083
-746
lines changed

38 files changed

+4083
-746
lines changed

src/textual/_compositor.py

+1
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,7 @@ def update_widgets(self, widgets: set[Widget]) -> None:
12291229
add_region = regions.append
12301230
get_widget = self.visible_widgets.__getitem__
12311231
for widget in self.visible_widgets.keys() & widgets:
1232+
widget._prepare_for_repaint()
12321233
region, clip = get_widget(widget)
12331234
offset = region.offset
12341235
intersection = clip.intersection

src/textual/app.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,8 @@ class App(Generic[ReturnType], DOMNode):
314314
scrollbar-background-active: ansi_default;
315315
scrollbar-color: ansi_blue;
316316
scrollbar-color-active: ansi_bright_blue;
317-
scrollbar-color-hover: ansi_bright_blue;
318-
scrollbar-corner-color: ansi_default;
317+
scrollbar-color-hover: ansi_bright_blue;
318+
scrollbar-corner-color: ansi_default;
319319
}
320320
321321
.bindings-table--key {
@@ -336,18 +336,18 @@ class App(Generic[ReturnType], DOMNode):
336336
}
337337
338338
/* When a widget is maximized */
339-
Screen.-maximized-view {
339+
Screen.-maximized-view {
340340
layout: vertical !important;
341341
hatch: right $panel;
342342
overflow-y: auto !important;
343343
align: center middle;
344344
.-maximized {
345-
dock: initial !important;
345+
dock: initial !important;
346346
}
347347
}
348348
/* Fade the header title when app is blurred */
349-
&:blur HeaderTitle {
350-
text-opacity: 50%;
349+
&:blur HeaderTitle {
350+
text-opacity: 50%;
351351
}
352352
}
353353
*:disabled:can-focus {
@@ -399,7 +399,7 @@ class MyApp(App[None]):
399399

400400
ALLOW_SELECT: ClassVar[bool] = True
401401
"""A switch to toggle arbitrary text selection for the app.
402-
402+
403403
Note that this doesn't apply to Input and TextArea which have builtin support for selection.
404404
"""
405405

@@ -445,7 +445,7 @@ class MyApp(App[None]):
445445
"""The default value of [Screen.ALLOW_IN_MAXIMIZED_VIEW][textual.screen.Screen.ALLOW_IN_MAXIMIZED_VIEW]."""
446446

447447
CLICK_CHAIN_TIME_THRESHOLD: ClassVar[float] = 0.5
448-
"""The maximum number of seconds between clicks to upgrade a single click to a double click,
448+
"""The maximum number of seconds between clicks to upgrade a single click to a double click,
449449
a double click to a triple click, etc."""
450450

451451
BINDINGS: ClassVar[list[BindingType]] = [
@@ -472,7 +472,7 @@ class MyApp(App[None]):
472472

473473
ESCAPE_TO_MINIMIZE: ClassVar[bool] = True
474474
"""Use escape key to minimize widgets (potentially overriding bindings).
475-
475+
476476
This is the default value, used if the active screen's `ESCAPE_TO_MINIMIZE` is not changed from `None`.
477477
"""
478478

@@ -544,7 +544,7 @@ def __init__(
544544

545545
self._registered_themes: dict[str, Theme] = {}
546546
"""Themes that have been registered with the App using `App.register_theme`.
547-
547+
548548
This excludes the built-in themes."""
549549

550550
for theme in BUILTIN_THEMES.values():
@@ -746,7 +746,7 @@ def __init__(
746746

747747
self.theme_changed_signal: Signal[Theme] = Signal(self, "theme-changed")
748748
"""Signal that is published when the App's theme is changed.
749-
749+
750750
Subscribers will receive the new theme object as an argument to the callback.
751751
"""
752752

src/textual/document/_document.py

+40-24
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
from abc import ABC, abstractmethod
44
from dataclasses import dataclass
55
from functools import lru_cache
6+
from itertools import zip_longest
67
from typing import TYPE_CHECKING, Callable, NamedTuple, Tuple, overload
78

89
from typing_extensions import Literal, get_args
910

1011
if TYPE_CHECKING:
11-
from tree_sitter import Node, Query
12+
from tree_sitter import Query
1213

1314
from textual._cells import cell_len
1415
from textual.geometry import Size
@@ -27,6 +28,10 @@ class EditResult:
2728
"""The new end Location after the edit is complete."""
2829
replaced_text: str
2930
"""The text that was replaced."""
31+
dirty_lines: range | None = None
32+
"""The range of lines considered dirty."""
33+
alt_dirty_line: tuple[int, range] | None = None
34+
"""Alternative list of lines considered dirty."""
3035

3136

3237
@lru_cache(maxsize=1024)
@@ -146,28 +151,6 @@ def clean_up(self) -> None:
146151
The default implementation does nothing.
147152
"""
148153

149-
def query_syntax_tree(
150-
self,
151-
query: "Query",
152-
start_point: tuple[int, int] | None = None,
153-
end_point: tuple[int, int] | None = None,
154-
) -> dict[str, list["Node"]]:
155-
"""Query the tree-sitter syntax tree.
156-
157-
The default implementation always returns an empty list.
158-
159-
To support querying in a subclass, this must be implemented.
160-
161-
Args:
162-
query: The tree-sitter Query to perform.
163-
start_point: The (row, column byte) to start the query at.
164-
end_point: The (row, column byte) to end the query at.
165-
166-
Returns:
167-
A dict mapping captured node names to lists of Nodes with that name.
168-
"""
169-
return {}
170-
171154
def set_syntax_tree_update_callback(
172155
callback: Callable[[], None],
173156
) -> None:
@@ -262,6 +245,10 @@ def newline(self) -> Newline:
262245
"""Get the Newline used in this document (e.g. '\r\n', '\n'. etc.)"""
263246
return self._newline
264247

248+
def copy_of_lines(self):
249+
"""Provide a copy of the document's lines."""
250+
return list(self._lines)
251+
265252
def get_size(self, tab_width: int) -> Size:
266253
"""The Size of the document, taking into account the tab rendering width.
267254
@@ -321,11 +308,40 @@ def replace_range(self, start: Location, end: Location, text: str) -> EditResult
321308
destination_column = len(before_selection)
322309
insert_lines = [before_selection + after_selection]
323310

311+
try:
312+
prev_top_line = lines[top_row]
313+
except IndexError:
314+
prev_top_line = None
324315
lines[top_row : bottom_row + 1] = insert_lines
325316
destination_row = top_row + len(insert_lines) - 1
326317

327318
end_location = (destination_row, destination_column)
328-
return EditResult(end_location, replaced_text)
319+
320+
n_previous_lines = bottom_row - top_row + 1
321+
dirty_range = None
322+
alt_dirty_line = None
323+
if len(insert_lines) != n_previous_lines:
324+
dirty_range = range(top_row, len(lines))
325+
else:
326+
if len(insert_lines) == 1 and prev_top_line is not None:
327+
rng = self._build_single_line_range(prev_top_line, insert_lines[0])
328+
if rng is not None:
329+
alt_dirty_line = top_row, rng
330+
else:
331+
dirty_range = range(top_row, bottom_row + 1)
332+
333+
return EditResult(end_location, replaced_text, dirty_range, alt_dirty_line)
334+
335+
@staticmethod
336+
def _build_single_line_range(a, b):
337+
rng = []
338+
for i, (ca, cb) in enumerate(zip_longest(a, b)):
339+
if ca != cb:
340+
rng.append(i)
341+
if rng:
342+
return range(rng[0], rng[-1] + 1)
343+
else:
344+
None
329345

330346
def get_text_range(self, start: Location, end: Location) -> str:
331347
"""Get the text that falls between the start and end locations.

src/textual/document/_document_navigator.py

+21
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from bisect import bisect, bisect_left, bisect_right
33
from typing import Any, Sequence
44

5+
from rich.cells import get_character_cell_size
6+
57
from textual._cells import cell_len
68
from textual.document._document import Location
79
from textual.document._wrapped_document import WrappedDocument
@@ -242,6 +244,16 @@ def get_location_left(self, location: Location) -> Location:
242244
length_of_row_above = len(self._document[row - 1])
243245
target_row = row if column != 0 else row - 1
244246
target_column = column - 1 if column != 0 else length_of_row_above
247+
248+
if target_row < self._document.line_count:
249+
line = self._document[target_row]
250+
if target_column < len(line):
251+
while target_column > 0:
252+
c = line[target_column]
253+
if c == "\t" or get_character_cell_size(c) > 0:
254+
break
255+
target_column -= 1
256+
245257
return target_row, target_column
246258

247259
def get_location_right(self, location: Location) -> Location:
@@ -263,6 +275,15 @@ def get_location_right(self, location: Location) -> Location:
263275
is_end_of_line = self.is_end_of_document_line(location)
264276
target_row = row + 1 if is_end_of_line else row
265277
target_column = 0 if is_end_of_line else column + 1
278+
279+
if target_row < self._document.line_count:
280+
line = self._document[target_row]
281+
while target_column < len(line):
282+
c = line[target_column]
283+
if c == "\t" or get_character_cell_size(c) > 0:
284+
break
285+
target_column += 1
286+
266287
return target_row, target_column
267288

268289
def get_location_above(self, location: Location) -> Location:

src/textual/document/_syntax_aware_document.py

+13-61
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
import weakref
44
from asyncio import CancelledError, Event, Task, create_task, sleep
5-
from contextlib import contextmanager
65
from functools import partial
7-
from typing import Callable, ContextManager, NamedTuple
6+
from typing import Callable, NamedTuple
87

98
try:
10-
from tree_sitter import Language, Node, Parser, Query, Tree
9+
from tree_sitter import Language, Parser, Query, Tree
1110

1211
TREE_SITTER = True
1312
except ImportError:
@@ -17,35 +16,6 @@
1716
from textual.document._document import Document, EditResult, Location, _utf8_encode
1817

1918

20-
@contextmanager
21-
def temporary_query_point_range(
22-
query: Query,
23-
start_point: tuple[int, int] | None,
24-
end_point: tuple[int, int] | None,
25-
) -> ContextManager[None]:
26-
"""Temporarily change the start and/or end point for a tree-sitter Query.
27-
28-
Args:
29-
query: The tree-sitter Query.
30-
start_point: The (row, column byte) to start the query at.
31-
end_point: The (row, column byte) to end the query at.
32-
"""
33-
# Note: Although not documented for the tree-sitter Python API, an
34-
# end-point of (0, 0) means 'end of document'.
35-
default_point_range = [(0, 0), (0, 0)]
36-
37-
point_range = list(default_point_range)
38-
if start_point is not None:
39-
point_range[0] = start_point
40-
if end_point is not None:
41-
point_range[1] = end_point
42-
query.set_point_range(point_range)
43-
try:
44-
yield None
45-
finally:
46-
query.set_point_range(default_point_range)
47-
48-
4919
class SyntaxTreeEdit(NamedTuple):
5020
"""Details of a tree-sitter syntax tree edit operation."""
5121

@@ -99,14 +69,15 @@ def __init__(
9969
self._background_parser = BackgroundSyntaxParser(self)
10070
self._pending_syntax_edits: list[SyntaxTreeEdit] = []
10171

72+
@property
73+
def current_syntax_tree(self) -> Tree:
74+
"""The current syntax tree."""
75+
return self._syntax_tree
76+
10277
def clean_up(self) -> None:
10378
"""Perform any pre-deletion clean up."""
10479
self._background_parser.stop()
10580

106-
def copy_of_lines(self):
107-
"""Provide a copy of the document's lines."""
108-
return list(self._lines)
109-
11081
def apply_pending_syntax_edits(self) -> bool:
11182
"""Apply any pending edits to the syntax tree.
11283
@@ -136,29 +107,6 @@ def prepare_query(self, query: str) -> Query | None:
136107
"""
137108
return self.language.query(query)
138109

139-
def query_syntax_tree(
140-
self,
141-
query: Query,
142-
start_point: tuple[int, int] | None = None,
143-
end_point: tuple[int, int] | None = None,
144-
) -> dict[str, list["Node"]]:
145-
"""Query the tree-sitter syntax tree.
146-
147-
The default implementation always returns an empty list.
148-
149-
To support querying in a subclass, this must be implemented.
150-
151-
Args:
152-
query: The tree-sitter Query to perform.
153-
start_point: The (row, column byte) to start the query at.
154-
end_point: The (row, column byte) to end the query at.
155-
156-
Returns:
157-
A tuple containing the nodes and text captured by the query.
158-
"""
159-
with temporary_query_point_range(query, start_point, end_point):
160-
return query.captures(self._syntax_tree.root_node)
161-
162110
def set_syntax_tree_update_callback(
163111
self,
164112
callback: Callable[[], None],
@@ -239,10 +187,14 @@ def reparse(self, timeout_us: int, lines: list[str], syntax_tree=None) -> bool:
239187
# The only known cause is a timeout.
240188
return False
241189
else:
190+
self._syntax_tree = tree
242191
if self._syntax_tree_update_callback is not None:
192+
193+
def set_new_tree():
194+
self._syntax_tree = tree
195+
243196
changed_ranges = self._syntax_tree.changed_ranges(tree)
244-
self._syntax_tree = tree
245-
self._syntax_tree_update_callback(changed_ranges)
197+
self._syntax_tree_update_callback(self._syntax_tree, len(lines))
246198
else:
247199
self._syntax_tree = tree
248200
return True

src/textual/document/_wrapped_document.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from bisect import bisect_right
4+
from functools import lru_cache
45

56
from rich.text import Text
67

@@ -85,6 +86,7 @@ def wrap(self, width: int, tab_width: int | None = None) -> None:
8586
tab_width: The maximum width to consider for tab characters. If None,
8687
reuse the tab width.
8788
"""
89+
self.get_sections.cache_clear()
8890
self._width = width
8991
if tab_width:
9092
self._tab_width = tab_width
@@ -168,6 +170,7 @@ def wrap_range(
168170
old_end: The old end location of the edit in document-space.
169171
new_end: The new end location of the edit in document-space.
170172
"""
173+
self.get_sections.cache_clear()
171174
start_line_index, _ = start
172175
old_end_line_index, _ = old_end
173176
new_end_line_index, _ = new_end
@@ -403,6 +406,7 @@ def get_target_document_column(
403406

404407
return target_column_index
405408

409+
@lru_cache(200)
406410
def get_sections(self, line_index: int) -> list[str]:
407411
"""Return the sections for the given line index.
408412

0 commit comments

Comments
 (0)