Skip to content

Commit cf7a350

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 baae8f5 commit cf7a350

File tree

39 files changed

+4139
-747
lines changed

39 files changed

+4139
-747
lines changed

src/textual/_compositor.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def __rich_console__(
230230
if y != last_y:
231231
yield new_line
232232

233-
def render_segments(self, console: Console) -> str:
233+
def render_segments(self, console: Console, prints=None) -> str:
234234
"""Render the update to raw data, suitable for writing to terminal.
235235
236236
Args:
@@ -261,11 +261,15 @@ def render_segments(self, console: Console) -> str:
261261
if x2 > x >= x1 and end <= x2:
262262
append(move_to(x, y).segment.text)
263263
append(strip.render(console))
264+
if prints is not None:
265+
prints.append(f" {y=} {x2, x, x1} {strip.text!r}")
264266
continue
265267

266268
strip = strip.crop(x1, min(end, x2) - x)
267269
append(move_to(x1, y).segment.text)
268270
append(strip.render(console))
271+
if prints is not None:
272+
prints.append(f" {y=} {x=} {strip.text!r}")
269273

270274
if y != last_y:
271275
append("\n")
@@ -1229,6 +1233,7 @@ def update_widgets(self, widgets: set[Widget]) -> None:
12291233
add_region = regions.append
12301234
get_widget = self.visible_widgets.__getitem__
12311235
for widget in self.visible_widgets.keys() & widgets:
1236+
widget._prepare_for_repaint()
12321237
region, clip = get_widget(widget)
12331238
offset = region.offset
12341239
intersection = clip.intersection

src/textual/_styles_cache.py

+27
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,33 @@ def render(
260260
x1, x2 = crop.column_span
261261
strips = [strip.crop(x1, x2) for strip in strips]
262262

263+
try:
264+
prints = _paul_prints
265+
except NameError:
266+
pass
267+
else:
268+
if prints is not None:
269+
if 1 in crop.line_range:
270+
# prints.append(f"STYLE strip[1] {strips[1 - crop.line_range.start].text!r}")
271+
# prints.append(f" crop={crop}")
272+
273+
if 0:
274+
import io
275+
import traceback
276+
277+
f = io.StringIO()
278+
traceback.print_stack(file=f, limit=10)
279+
lines = []
280+
for line in f.getvalue().splitlines():
281+
if line.lstrip().startswith('File "/home'):
282+
line = line.replace(
283+
'"/home/paul/np/os/python/textual/textual/fork/'
284+
"src/textual/",
285+
'"',
286+
)
287+
lines.append(line)
288+
prints.append("\n".join(lines))
289+
263290
return strips
264291

265292
def render_line(

src/textual/app.py

+18-11
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,8 @@ class App(Generic[ReturnType], DOMNode):
313313
scrollbar-background-active: ansi_default;
314314
scrollbar-color: ansi_blue;
315315
scrollbar-color-active: ansi_bright_blue;
316-
scrollbar-color-hover: ansi_bright_blue;
317-
scrollbar-corner-color: ansi_default;
316+
scrollbar-color-hover: ansi_bright_blue;
317+
scrollbar-corner-color: ansi_default;
318318
}
319319
320320
.bindings-table--key {
@@ -335,18 +335,18 @@ class App(Generic[ReturnType], DOMNode):
335335
}
336336
337337
/* When a widget is maximized */
338-
Screen.-maximized-view {
338+
Screen.-maximized-view {
339339
layout: vertical !important;
340340
hatch: right $panel;
341341
overflow-y: auto !important;
342342
align: center middle;
343343
.-maximized {
344-
dock: initial !important;
344+
dock: initial !important;
345345
}
346346
}
347347
/* Fade the header title when app is blurred */
348-
&:blur HeaderTitle {
349-
text-opacity: 50%;
348+
&:blur HeaderTitle {
349+
text-opacity: 50%;
350350
}
351351
}
352352
*:disabled:can-focus {
@@ -398,7 +398,7 @@ class MyApp(App[None]):
398398

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

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

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

450450
BINDINGS: ClassVar[list[BindingType]] = [
@@ -471,7 +471,7 @@ class MyApp(App[None]):
471471

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

@@ -543,7 +543,7 @@ def __init__(
543543

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

549549
for theme in BUILTIN_THEMES.values():
@@ -745,7 +745,7 @@ def __init__(
745745

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

@@ -3595,6 +3595,13 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
35953595
write(chunk)
35963596
else:
35973597
self._driver.write(terminal_sequence)
3598+
try:
3599+
prints = _paul_ext_prints
3600+
except NameError:
3601+
pass
3602+
else:
3603+
if 0 and prints is not None:
3604+
prints.append(f"Seq: {terminal_sequence[:400]!r}")
35983605
finally:
35993606
self._end_update()
36003607

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:

0 commit comments

Comments
 (0)