Skip to content

Commit 8d78427

Browse files
authored
Merge pull request #6057 from Textualize/styles-cache-optimization
optimizations in styles cache
2 parents ba68936 + 515b82f commit 8d78427

File tree

59 files changed

+1180
-1168
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+1180
-1168
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2020
- Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048
2121
- Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048
2222
- Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048
23+
- Added `Widget.get_line_filters` and `App.get_line_filters` https://github.com/Textualize/textual/pull/6057
2324

2425
### Changed
2526

2627
- Breaking change: The `renderable` property on the `Static` widget has been changed to `content`. https://github.com/Textualize/textual/pull/6041
2728
- Breaking change: Renamed `Label` constructor argument `renderable` to `content` for consistency https://github.com/Textualize/textual/pull/6045
29+
- Breaking change: Optimization to line API to avoid applying background styles to widget content. In practice this means that you can no longer rely on blank Segments automatically getting the background color.
2830

2931
# [5.3.0] - 2025-08-07
3032

src/textual/_segment_tools.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import annotations
66

77
import re
8+
from functools import lru_cache
89
from typing import Iterable
910

1011
from rich.segment import Segment
@@ -15,6 +16,20 @@
1516
from textual.geometry import Size
1617

1718

19+
@lru_cache(1024 * 8)
20+
def make_blank(width, style: Style) -> Segment:
21+
"""Make a blank segment.
22+
23+
Args:
24+
width: Width of blank.
25+
style: Style of blank.
26+
27+
Returns:
28+
A single segment
29+
"""
30+
return Segment(" " * width, style)
31+
32+
1833
class NoCellPositionForIndex(Exception):
1934
pass
2035

@@ -162,19 +177,19 @@ def line_pad(
162177
"""
163178
if pad_left and pad_right:
164179
return [
165-
Segment(" " * pad_left, style),
180+
make_blank(pad_left, style),
166181
*segments,
167-
Segment(" " * pad_right, style),
182+
make_blank(pad_right, style),
168183
]
169184
elif pad_left:
170185
return [
171-
Segment(" " * pad_left, style),
186+
make_blank(pad_left, style),
172187
*segments,
173188
]
174189
elif pad_right:
175190
return [
176191
*segments,
177-
Segment(" " * pad_right, style),
192+
make_blank(pad_right, style),
178193
]
179194
return list(segments)
180195

@@ -215,7 +230,7 @@ def blank_lines(count: int) -> list[list[Segment]]:
215230
Returns:
216231
A list of blank lines.
217232
"""
218-
return [[Segment(" " * width, style)]] * count
233+
return [[make_blank(width, style)]] * count
219234

220235
top_blank_lines = bottom_blank_lines = 0
221236
vertical_excess_space = max(0, height - shape_height)

src/textual/_styles_cache.py

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
from functools import lru_cache
4-
from sys import intern
54
from typing import TYPE_CHECKING, Callable, Iterable, Sequence
65

76
import rich.repr
@@ -14,7 +13,7 @@
1413
from textual._border import get_box, render_border_label, render_row
1514
from textual._context import active_app
1615
from textual._opacity import _apply_opacity
17-
from textual._segment_tools import apply_hatch, line_pad, line_trim
16+
from textual._segment_tools import apply_hatch, line_pad, line_trim, make_blank
1817
from textual.color import TRANSPARENT, Color
1918
from textual.constants import DEBUG
2019
from textual.content import Content
@@ -34,20 +33,6 @@
3433
RenderLineCallback: TypeAlias = Callable[[int], Strip]
3534

3635

37-
@lru_cache(1024 * 8)
38-
def make_blank(width, style: RichStyle) -> Segment:
39-
"""Make a blank segment.
40-
41-
Args:
42-
width: Width of blank.
43-
style: Style of blank.
44-
45-
Returns:
46-
A single segment
47-
"""
48-
return Segment(intern(" " * width), style)
49-
50-
5136
@rich.repr.auto(angular=True)
5237
class StylesCache:
5338
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
@@ -105,6 +90,7 @@ def is_dirty(self, y: int) -> bool:
10590

10691
def clear(self) -> None:
10792
"""Clear the styles cache (will cause the content to re-render)."""
93+
10894
self._cache.clear()
10995
self._dirty_lines.clear()
11096

@@ -122,14 +108,15 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
122108
border_title = widget._border_title
123109
border_subtitle = widget._border_subtitle
124110

125-
base_background, background = widget._opacity_background_colors
111+
base_background, background = widget.background_colors
126112
styles = widget.styles
127113
strips = self.render(
128114
styles,
129115
widget.region.size,
130116
base_background,
131117
background,
132118
widget.render_line,
119+
widget.get_line_filters(),
133120
(
134121
None
135122
if border_title is None
@@ -149,7 +136,6 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
149136
content_size=widget.content_region.size,
150137
padding=styles.padding,
151138
crop=crop,
152-
filters=widget.app._filters,
153139
opacity=widget.opacity,
154140
ansi_theme=widget.app.ansi_theme,
155141
)
@@ -177,12 +163,12 @@ def render(
177163
base_background: Color,
178164
background: Color,
179165
render_content_line: RenderLineCallback,
166+
filters: Sequence[LineFilter],
180167
border_title: tuple[Content, Color, Color, Style] | None,
181168
border_subtitle: tuple[Content, Color, Color, Style] | None,
182169
content_size: Size | None = None,
183170
padding: Spacing | None = None,
184171
crop: Region | None = None,
185-
filters: Sequence[LineFilter] | None = None,
186172
opacity: float = 1.0,
187173
ansi_theme: TerminalTheme = DEFAULT_TERMINAL_THEME,
188174
) -> list[Strip]:
@@ -223,9 +209,7 @@ def render(
223209

224210
is_dirty = self._dirty_lines.__contains__
225211
render_line = self.render_line
226-
apply_filters = (
227-
[] if filters is None else [filter for filter in filters if filter.enabled]
228-
)
212+
229213
for y in crop.line_range:
230214
if is_dirty(y) or y not in self._cache:
231215
strip = render_line(
@@ -246,7 +230,7 @@ def render(
246230
else:
247231
strip = self._cache[y]
248232

249-
for filter in apply_filters:
233+
for filter in filters:
250234
strip = strip.apply_filter(filter, background)
251235

252236
if DEBUG:
@@ -263,6 +247,16 @@ def render(
263247

264248
return strips
265249

250+
@lru_cache(1024)
251+
def get_inner_outer(
252+
cls, base_background: Color, background: Color
253+
) -> tuple[Style, Style]:
254+
"""Get inner and outer background colors."""
255+
return (
256+
Style(background=base_background + background),
257+
Style(background=base_background),
258+
)
259+
266260
def render_line(
267261
self,
268262
styles: StylesBase,
@@ -319,9 +313,7 @@ def render_line(
319313
) = styles.outline
320314

321315
from_color = RichStyle.from_color
322-
323-
inner = Style(background=(base_background + background))
324-
outer = Style(background=base_background)
316+
inner, outer = self.get_inner_outer(base_background, background)
325317

326318
def line_post(segments: Iterable[Segment]) -> Iterable[Segment]:
327319
"""Apply effects to segments inside the border."""
@@ -343,7 +335,6 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
343335
Returns:
344336
New list of segments
345337
"""
346-
347338
try:
348339
app = active_app.get()
349340
ansi_theme = app.ansi_theme
@@ -361,7 +352,6 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
361352
line: Iterable[Segment]
362353
# Draw top or bottom borders (A)
363354
if (border_top and y == 0) or (border_bottom and y == height - 1):
364-
365355
is_top = y == 0
366356
border_color = base_background + (
367357
border_top_color if is_top else border_bottom_color
@@ -427,7 +417,7 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
427417
elif (pad_top and y < gutter.top) or (
428418
pad_bottom and y >= height - gutter.bottom
429419
):
430-
background_rich_style = from_color(bgcolor=background.rich_color)
420+
background_rich_style = inner.rich_style
431421
left_style = Style(
432422
foreground=base_background + border_left_color.multiply_alpha(opacity)
433423
)
@@ -450,15 +440,12 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
450440
content_y = y - gutter.top
451441
if content_y < content_height:
452442
line = render_content_line(y - gutter.top)
453-
line = line.adjust_cell_length(content_width)
443+
line = line.adjust_cell_length(content_width, inner.rich_style)
454444
else:
455-
line = [make_blank(content_width, inner.rich_style)]
456-
if inner:
457-
line = Segment.apply_style(line, inner.rich_style)
458-
if styles.text_opacity != 1.0:
459-
line = TextOpacity.process_segments(
460-
line, styles.text_opacity, ansi_theme
461-
)
445+
line = Strip.blank(content_width, inner.rich_style)
446+
447+
if (text_opacity := styles.text_opacity) != 1.0:
448+
line = TextOpacity.process_segments(line, text_opacity, ansi_theme)
462449
line = line_post(line_pad(line, pad_left, pad_right, inner.rich_style))
463450

464451
if border_left or border_right:

src/textual/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,14 @@ def __init__(
842842
)
843843
)
844844

845+
def get_line_filters(self) -> Sequence[LineFilter]:
846+
"""Get currently enabled line filters.
847+
848+
Returns:
849+
A list of [LineFilter][textual.filters.LineFilter] instances.
850+
"""
851+
return [filter for filter in self._filters if filter.enabled]
852+
845853
@property
846854
def _is_devtools_connected(self) -> bool:
847855
"""Is the app connected to the devtools?"""
@@ -3160,7 +3168,6 @@ async def _process_messages(
31603168
terminal_size: tuple[int, int] | None = None,
31613169
message_hook: Callable[[Message], None] | None = None,
31623170
) -> None:
3163-
31643171
self._thread_init()
31653172

31663173
async def app_prelude() -> bool:

src/textual/dom.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,26 +1146,12 @@ def _get_subtitle_style_information(
11461146

11471147
@property
11481148
def background_colors(self) -> tuple[Color, Color]:
1149-
"""The background color and the color of the parent's background.
1150-
1151-
Returns:
1152-
`(<background color>, <color>)`
1153-
"""
1154-
base_background = background = BLACK
1155-
for node in reversed(self.ancestors_with_self):
1156-
styles = node.styles
1157-
base_background = background
1158-
background += styles.background.tint(styles.background_tint)
1159-
return (base_background, background)
1160-
1161-
@property
1162-
def _opacity_background_colors(self) -> tuple[Color, Color]:
11631149
"""Background colors adjusted for opacity.
11641150
11651151
Returns:
11661152
`(<background color>, <color>)`
11671153
"""
1168-
base_background = background = BLACK
1154+
base_background = background = Color(0, 0, 0, 0)
11691155
opacity = 1.0
11701156
for node in reversed(self.ancestors_with_self):
11711157
styles = node.styles

src/textual/filter.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -240,22 +240,26 @@ def truecolor_style(self, style: Style, background: RichColor) -> Style:
240240
New style.
241241
"""
242242
terminal_theme = self._terminal_theme
243-
color = style.color
244-
if color is not None and color.triplet is None:
245-
color = RichColor.from_rgb(
246-
*color.get_truecolor(terminal_theme, foreground=True)
247-
)
248-
bgcolor = style.bgcolor
249-
if bgcolor is not None and bgcolor.triplet is None:
250-
bgcolor = RichColor.from_rgb(
251-
*bgcolor.get_truecolor(terminal_theme, foreground=False)
243+
244+
changed = False
245+
if (color := style.color) is not None:
246+
if color.triplet is None:
247+
color = RichColor.from_triplet(
248+
color.get_truecolor(terminal_theme, foreground=True)
249+
)
250+
changed = True
251+
if style.dim:
252+
color = dim_color(background, color)
253+
style += NO_DIM
254+
changed = True
255+
256+
if (bgcolor := style.bgcolor) is not None and bgcolor.triplet is None:
257+
bgcolor = RichColor.from_triplet(
258+
bgcolor.get_truecolor(terminal_theme, foreground=False)
252259
)
253-
# Convert dim style to RGB
254-
if style.dim and color is not None:
255-
color = dim_color(background, color)
256-
style += NO_DIM
260+
changed = True
257261

258-
return style + Style.from_color(color, bgcolor)
262+
return style + Style.from_color(color, bgcolor) if changed else style
259263

260264
def apply(self, segments: list[Segment], background: Color) -> list[Segment]:
261265
"""Transform a list of segments.
@@ -269,9 +273,7 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]:
269273
"""
270274
_Segment = Segment
271275
truecolor_style = self.truecolor_style
272-
273276
background_rich_color = background.rich_color
274-
275277
return [
276278
_Segment(
277279
text,

src/textual/renderables/text_opacity.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def process_segments(
7979
):
8080
invisible_style = _from_color(bgcolor=style.bgcolor)
8181
yield _Segment(cell_len(text) * " ", invisible_style)
82+
elif opacity == 1:
83+
yield from segments
8284
else:
8385
filter = ANSIToTruecolor(ansi_theme)
8486
for segment in filter.apply(list(segments), TRANSPARENT):

src/textual/scroll_view.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ def watch_scroll_x(self, old_value: float, new_value: float) -> None:
3636
if self.show_horizontal_scrollbar:
3737
self.horizontal_scrollbar.position = new_value
3838
if round(old_value) != round(new_value):
39-
self.refresh()
39+
self.refresh(self.size.region)
4040

4141
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
4242
if self.show_vertical_scrollbar:
4343
self.vertical_scrollbar.position = new_value
4444
if round(old_value) != round(new_value):
45-
self.refresh()
45+
self.refresh(self.size.region)
4646

4747
def on_mount(self):
4848
self._refresh_scrollbars()

src/textual/scrollbar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ def render(self) -> RenderableType:
287287
background = styles.scrollbar_background
288288
color = styles.scrollbar_color
289289
if background.a < 1:
290-
base_background, _ = self.parent._opacity_background_colors
290+
base_background, _ = self.parent.background_colors
291291
background = base_background + background
292292
color = background + color
293293
scrollbar_style = Style.from_color(color.rich_color, background.rich_color)

0 commit comments

Comments
 (0)