Skip to content

Background tree-sitter incremental parsing to improve TextArea responsiveness. #5645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `Style.has_transparent_foreground` property https://github.com/Textualize/textual/pull/5657


### Fixed

- Fixed TextArea's syntax highlighting. Some highlighting details were not being
applied. For example, in CSS, the text 'padding: 10px 0;' was shown in a
single colour. Now the 'px' appears in a different colour to the rest of the
text.

- Fixed some situations where editing for syntax highlighed TextArea widgets with
large documents was very unresponsive.


## [2.1.2] - 2025-02-26

### Fixed
Expand Down
36 changes: 18 additions & 18 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "Modern Text User Interface framework"
authors = ["Will McGugan <[email protected]>"]
license = "MIT"
readme = "README.md"
classifiers = [
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
Expand Down Expand Up @@ -52,24 +52,24 @@ typing-extensions = "^4.4.0"
platformdirs = ">=3.6.0,<5"

# start of [syntax] extras
# Require tree-sitter >= 0.23.0 and python >= 3.9
# Require tree-sitter >= 0.24.0 and python >= 3.10
# Windows, MacOS and Linux binary wheels are available for all of the languages below.
tree-sitter = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-python = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-markdown = { version = ">=0.3.0", optional = true, python = ">=3.9"}
tree-sitter-json = { version = ">=0.24.0", optional = true, python = ">=3.9" }
tree-sitter-toml = { version = ">=0.6.0", optional = true, python = ">=3.9" }
tree-sitter-yaml = { version = ">=0.6.0", optional = true, python = ">=3.9" }
tree-sitter-html = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-css = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-javascript = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-rust = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-go = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-regex = { version = ">=0.24.0", optional = true, python = ">=3.9" }
tree-sitter-xml = { version = ">=0.7.0", optional = true, python = ">=3.9" }
tree-sitter-sql = { version = ">=0.3.0,<0.3.8", optional = true, python = ">=3.9" }
tree-sitter-java = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-bash = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter = { version = ">=0.24.0", optional = true, python = ">=3.10" }
tree-sitter-python = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-markdown = { version = ">=0.3.0", optional = true, python = ">=3.10"}
tree-sitter-json = { version = ">=0.24.0", optional = true, python = ">=3.10" }
tree-sitter-toml = { version = ">=0.6.0", optional = true, python = ">=3.10" }
tree-sitter-yaml = { version = ">=0.6.0", optional = true, python = ">=3.10" }
tree-sitter-html = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-css = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-javascript = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-rust = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-go = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-regex = { version = ">=0.24.0", optional = true, python = ">=3.10" }
tree-sitter-xml = { version = ">=0.7.0", optional = true, python = ">=3.10" }
tree-sitter-sql = { version = ">=0.3.0,<0.3.8", optional = true, python = ">=3.10" }
tree-sitter-java = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-bash = { version = ">=0.23.0", optional = true, python = ">=3.10" }
# end of [syntax] extras

[tool.poetry.extras]
Expand Down
13 changes: 9 additions & 4 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def __init__(

Args:
chops: A mapping of offsets to list of segments, per line.
crop: Region to restrict update to.
spans: Line spans to restrict update to.
chop_ends: A list of the end offsets for each line
"""
self.chops = chops
Expand Down Expand Up @@ -230,7 +230,7 @@ def __rich_console__(
if y != last_y:
yield new_line

def render_segments(self, console: Console) -> str:
def render_segments(self, console: Console, prints=None) -> str:
"""Render the update to raw data, suitable for writing to terminal.

Args:
Expand Down Expand Up @@ -261,11 +261,15 @@ def render_segments(self, console: Console) -> str:
if x2 > x >= x1 and end <= x2:
append(move_to(x, y).segment.text)
append(strip.render(console))
if prints is not None:
prints.append(f" {y=} {x2, x, x1} {strip.text!r}")
continue

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

if y != last_y:
append("\n")
Expand Down Expand Up @@ -1229,6 +1233,7 @@ def update_widgets(self, widgets: set[Widget]) -> None:
add_region = regions.append
get_widget = self.visible_widgets.__getitem__
for widget in self.visible_widgets.keys() & widgets:
widget._prepare_for_repaint()
region, clip = get_widget(widget)
offset = region.offset
intersection = clip.intersection
Expand Down
27 changes: 27 additions & 0 deletions src/textual/_styles_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,33 @@ def render(
x1, x2 = crop.column_span
strips = [strip.crop(x1, x2) for strip in strips]

try:
prints = _paul_prints
except NameError:
pass
else:
if prints is not None:
if 1 in crop.line_range:
# prints.append(f"STYLE strip[1] {strips[1 - crop.line_range.start].text!r}")
# prints.append(f" crop={crop}")

if 0:
import io
import traceback

f = io.StringIO()
traceback.print_stack(file=f, limit=10)
lines = []
for line in f.getvalue().splitlines():
if line.lstrip().startswith('File "/home'):
line = line.replace(
'"/home/paul/np/os/python/textual/textual/fork/'
"src/textual/",
'"',
)
lines.append(line)
prints.append("\n".join(lines))

return strips

def render_line(
Expand Down
29 changes: 18 additions & 11 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,8 @@ class App(Generic[ReturnType], DOMNode):
scrollbar-background-active: ansi_default;
scrollbar-color: ansi_blue;
scrollbar-color-active: ansi_bright_blue;
scrollbar-color-hover: ansi_bright_blue;
scrollbar-corner-color: ansi_default;
scrollbar-color-hover: ansi_bright_blue;
scrollbar-corner-color: ansi_default;
}

.bindings-table--key {
Expand All @@ -336,18 +336,18 @@ class App(Generic[ReturnType], DOMNode):
}

/* When a widget is maximized */
Screen.-maximized-view {
Screen.-maximized-view {
layout: vertical !important;
hatch: right $panel;
overflow-y: auto !important;
align: center middle;
.-maximized {
dock: initial !important;
dock: initial !important;
}
}
/* Fade the header title when app is blurred */
&:blur HeaderTitle {
text-opacity: 50%;
&:blur HeaderTitle {
text-opacity: 50%;
}
}
*:disabled:can-focus {
Expand Down Expand Up @@ -399,7 +399,7 @@ class MyApp(App[None]):

ALLOW_SELECT: ClassVar[bool] = True
"""A switch to toggle arbitrary text selection for the app.

Note that this doesn't apply to Input and TextArea which have builtin support for selection.
"""

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

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

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

ESCAPE_TO_MINIMIZE: ClassVar[bool] = True
"""Use escape key to minimize widgets (potentially overriding bindings).

This is the default value, used if the active screen's `ESCAPE_TO_MINIMIZE` is not changed from `None`.
"""

Expand Down Expand Up @@ -544,7 +544,7 @@ def __init__(

self._registered_themes: dict[str, Theme] = {}
"""Themes that have been registered with the App using `App.register_theme`.

This excludes the built-in themes."""

for theme in BUILTIN_THEMES.values():
Expand Down Expand Up @@ -746,7 +746,7 @@ def __init__(

self.theme_changed_signal: Signal[Theme] = Signal(self, "theme-changed")
"""Signal that is published when the App's theme is changed.

Subscribers will receive the new theme object as an argument to the callback.
"""

Expand Down Expand Up @@ -3596,6 +3596,13 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
write(chunk)
else:
self._driver.write(terminal_sequence)
try:
prints = _paul_ext_prints
except NameError:
pass
else:
if 0 and prints is not None:
prints.append(f"Seq: {terminal_sequence[:400]!r}")
finally:
self._end_update()

Expand Down
79 changes: 61 additions & 18 deletions src/textual/document/_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, NamedTuple, Tuple, overload
from itertools import zip_longest
from typing import TYPE_CHECKING, Callable, NamedTuple, Tuple, overload

from typing_extensions import Literal, get_args

if TYPE_CHECKING:
from tree_sitter import Node, Query
from tree_sitter import Query

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


@lru_cache(maxsize=1024)
Expand Down Expand Up @@ -140,27 +145,32 @@ def get_size(self, indent_width: int) -> Size:
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.
def clean_up(self) -> None:
"""Perform any pre-deletion clean up.

The default implementation always returns an empty list.
The default implementation does nothing.
"""

def set_syntax_tree_update_callback(
callback: Callable[[], None],
) -> None:
"""Set a callback function for signalling a rebuild of the syntax tree.

To support querying in a subclass, this must be implemented.
The default implementation does nothing.

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.
callback: A function that takes no arguments and returns None.
"""

Returns:
A dict mapping captured node names to lists of Nodes with that name.
def trigger_syntax_tree_update(self, force_update: bool = False) -> None:
"""Trigger a new syntax tree update to run in the background.

The default implementation does nothing.

Args:
force_update: When set, ensure that the syntax tree is regenerated
unconditionally.
"""
return {}

def prepare_query(self, query: str) -> "Query | None":
return None
Expand Down Expand Up @@ -235,6 +245,10 @@ def newline(self) -> Newline:
"""Get the Newline used in this document (e.g. '\r\n', '\n'. etc.)"""
return self._newline

def copy_of_lines(self):
"""Provide a copy of the document's lines."""
return list(self._lines)

def get_size(self, tab_width: int) -> Size:
"""The Size of the document, taking into account the tab rendering width.

Expand Down Expand Up @@ -294,11 +308,40 @@ def replace_range(self, start: Location, end: Location, text: str) -> EditResult
destination_column = len(before_selection)
insert_lines = [before_selection + after_selection]

try:
prev_top_line = lines[top_row]
except IndexError:
prev_top_line = None
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)

n_previous_lines = bottom_row - top_row + 1
dirty_range = None
alt_dirty_line = None
if len(insert_lines) != n_previous_lines:
dirty_range = range(top_row, len(lines))
else:
if len(insert_lines) == 1 and prev_top_line is not None:
rng = self._build_single_line_range(prev_top_line, insert_lines[0])
if rng is not None:
alt_dirty_line = top_row, rng
else:
dirty_range = range(top_row, bottom_row + 1)

return EditResult(end_location, replaced_text, dirty_range, alt_dirty_line)

@staticmethod
def _build_single_line_range(a, b):
rng = []
for i, (ca, cb) in enumerate(zip_longest(a, b)):
if ca != cb:
rng.append(i)
if rng:
return range(rng[0], rng[-1] + 1)
else:
None

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