Skip to content

Commit c2b964f

Browse files
authored
Merge pull request #5925 from Textualize/textarea-optimizations
Textarea optimizations
2 parents 34090d8 + 9267703 commit c2b964f

13 files changed

+346
-289
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1515
## Changed
1616

1717
- Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900
18+
- Some optimizations to TextArea, which may be noticeable during scrolling (note: may break snapshots with a TextArea) https://github.com/Textualize/textual/pull/5925
19+
- Selecting in the TextArea now hides the cursor until you release the mouse https://github.com/Textualize/textual/pull/5925
20+
- Read only TextAreas will no longer display a cursor https://github.com/Textualize/textual/pull/5925
1821

1922
## Added
2023

src/textual/document/_document.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,3 +466,8 @@ def is_empty(self) -> bool:
466466
"""Return True if the selection has 0 width, i.e. it's just a cursor."""
467467
start, end = self
468468
return start == end
469+
470+
def contains_line(self, y: int) -> bool:
471+
"""Check if the given line is within the selection."""
472+
top, bottom = sorted((self.start[0], self.end[0]))
473+
return y >= top and y <= bottom

src/textual/widgets/_text_area.py

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple
1010

1111
from rich.console import RenderableType
12+
from rich.segment import Segment
1213
from rich.style import Style
1314
from rich.text import Text
1415
from typing_extensions import Literal
1516

1617
from textual._text_area_theme import TextAreaTheme
1718
from textual._tree_sitter import TREE_SITTER, get_language
19+
from textual.cache import LRUCache
1820
from textual.color import Color
1921
from textual.document._document import (
2022
Document,
@@ -511,6 +513,8 @@ def __init__(
511513
self.set_reactive(TextArea.line_number_start, line_number_start)
512514
self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line)
513515

516+
self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024)
517+
514518
self._set_document(text, language)
515519

516520
self.language = language
@@ -610,6 +614,9 @@ def _get_builtin_highlight_query(language_name: str) -> str:
610614

611615
return highlight_query
612616

617+
def notify_style_update(self) -> None:
618+
self._line_cache.clear()
619+
613620
def check_consume_key(self, key: str, character: str | None = None) -> bool:
614621
"""Check if the widget may consume the given key.
615622
@@ -633,6 +640,7 @@ def check_consume_key(self, key: str, character: str | None = None) -> bool:
633640

634641
def _build_highlight_map(self) -> None:
635642
"""Query the tree for ranges to highlights, and update the internal highlights mapping."""
643+
self._line_cache.clear()
636644
highlights = self._highlights
637645
highlights.clear()
638646
if not self._highlight_query:
@@ -1035,6 +1043,7 @@ def wrap_width(self) -> int:
10351043

10361044
def _rewrap_and_refresh_virtual_size(self) -> None:
10371045
self.wrapped_document.wrap(self.wrap_width, tab_width=self.indent_width)
1046+
self._line_cache.clear()
10381047
self._refresh_size()
10391048

10401049
@property
@@ -1105,20 +1114,67 @@ def get_line(self, line_index: int) -> Text:
11051114
A `rich.Text` object containing the requested line.
11061115
"""
11071116
line_string = self.document.get_line(line_index)
1108-
return Text(line_string, end="")
1117+
return Text(line_string, end="", no_wrap=True)
1118+
1119+
def render_lines(self, crop: Region) -> list[Strip]:
1120+
theme = self._theme
1121+
if theme:
1122+
theme.apply_css(self)
1123+
return super().render_lines(crop)
11091124

11101125
def render_line(self, y: int) -> Strip:
11111126
"""Render a single line of the TextArea. Called by Textual.
11121127
1128+
Args:
1129+
y: Y Coordinate of line relative to the widget region.
1130+
1131+
Returns:
1132+
A rendered line.
1133+
"""
1134+
scroll_x, scroll_y = self.scroll_offset
1135+
absolute_y = scroll_y + y
1136+
selection = self.selection
1137+
cache_key = (
1138+
self.size,
1139+
scroll_x,
1140+
absolute_y,
1141+
(
1142+
selection
1143+
if selection.contains_line(absolute_y)
1144+
else selection.end[0] == absolute_y
1145+
),
1146+
(
1147+
selection.end
1148+
if (
1149+
self._cursor_visible
1150+
and self.cursor_blink
1151+
and absolute_y == selection.end[0]
1152+
)
1153+
else None
1154+
),
1155+
self.theme,
1156+
self._matching_bracket_location,
1157+
self.match_cursor_bracket,
1158+
self.soft_wrap,
1159+
self.show_line_numbers,
1160+
self.read_only,
1161+
)
1162+
if (cached_line := self._line_cache.get(cache_key)) is not None:
1163+
return cached_line
1164+
line = self._render_line(y)
1165+
self._line_cache[cache_key] = line
1166+
return line
1167+
1168+
def _render_line(self, y: int) -> Strip:
1169+
"""Render a single line of the TextArea. Called by Textual.
1170+
11131171
Args:
11141172
y: Y Coordinate of line relative to the widget region.
11151173
11161174
Returns:
11171175
A rendered line.
11181176
"""
11191177
theme = self._theme
1120-
if theme:
1121-
theme.apply_css(self)
11221178

11231179
wrapped_document = self.wrapped_document
11241180
scroll_x, scroll_y = self.scroll_offset
@@ -1173,7 +1229,8 @@ def render_line(self, y: int) -> Strip:
11731229
if selection_style:
11741230
if line_character_count == 0 and line_index != cursor_row:
11751231
# A simple highlight to show empty lines are included in the selection
1176-
line = Text("▌", end="", style=Style(color=selection_style.bgcolor))
1232+
line.plain = "▌"
1233+
line.stylize(Style(color=selection_style.bgcolor))
11771234
else:
11781235
if line_index == selection_top_row == selection_bottom_row:
11791236
# Selection within a single line
@@ -1222,6 +1279,7 @@ def render_line(self, y: int) -> Strip:
12221279
self.has_focus
12231280
and not self.cursor_blink
12241281
or (self.cursor_blink and self._cursor_visible)
1282+
and not self.read_only
12251283
)
12261284
if draw_matched_brackets:
12271285
matching_bracket_style = theme.bracket_matching_style if theme else None
@@ -1263,13 +1321,11 @@ def render_line(self, y: int) -> Strip:
12631321
gutter_content = (
12641322
str(line_index + self.line_number_start) if section_offset == 0 else ""
12651323
)
1266-
gutter = Text(
1267-
f"{gutter_content:>{gutter_width_no_margin}} ",
1268-
style=gutter_style or "",
1269-
end="",
1270-
)
1324+
gutter = [
1325+
Segment(f"{gutter_content:>{gutter_width_no_margin}} ", gutter_style)
1326+
]
12711327
else:
1272-
gutter = Text("", end="")
1328+
gutter = []
12731329

12741330
# TODO: Lets not apply the division each time through render_line.
12751331
# We should cache sections with the edit counts.
@@ -1304,17 +1360,10 @@ def render_line(self, y: int) -> Strip:
13041360
else max(virtual_width, self.region.size.width)
13051361
)
13061362
target_width = base_width - self.gutter_width
1307-
console = self.app.console
1308-
gutter_segments = console.render(gutter)
1309-
1310-
text_segments = list(
1311-
console.render(line, console.options.update_width(target_width))
1312-
)
1313-
1314-
gutter_strip = Strip(gutter_segments, cell_length=gutter_width)
1315-
text_strip = Strip(text_segments)
13161363

13171364
# Crop the line to show only the visible part (some may be scrolled out of view)
1365+
console = self.app.console
1366+
text_strip = Strip(line.render(console), cell_length=line.cell_len)
13181367
if not self.soft_wrap:
13191368
text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width)
13201369

@@ -1325,7 +1374,7 @@ def render_line(self, y: int) -> Strip:
13251374
line_style = theme.base_style if theme else None
13261375

13271376
text_strip = text_strip.extend_cell_length(target_width, line_style)
1328-
strip = Strip.join([gutter_strip, text_strip]).simplify()
1377+
strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip])
13291378

13301379
return strip.apply_style(
13311380
theme.base_style
@@ -1645,7 +1694,7 @@ async def _on_mouse_down(self, event: events.MouseDown) -> None:
16451694
# Capture the mouse so that if the cursor moves outside the
16461695
# TextArea widget while selecting, the widget still scrolls.
16471696
self.capture_mouse()
1648-
self._pause_blink(visible=True)
1697+
self._pause_blink(visible=False)
16491698
self.history.checkpoint()
16501699

16511700
async def _on_mouse_move(self, event: events.MouseMove) -> None:

0 commit comments

Comments
 (0)