Skip to content

Commit de66343

Browse files
authored
Merge pull request #6166 from Textualize/faster-layout
Faster layout
2 parents 08a7e26 + 1e047c7 commit de66343

File tree

10 files changed

+95
-25
lines changed

10 files changed

+95
-25
lines changed

src/textual/_arrange.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ def arrange(
5353
Returns:
5454
Widget arrangement information.
5555
"""
56-
5756
placements: list[WidgetPlacement] = []
5857
scroll_spacing = NULL_SPACING
5958
styles = widget.styles

src/textual/_compositor.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,8 +1248,7 @@ def update_widgets(self, widgets: set[Widget]) -> None:
12481248
offset = region.offset
12491249
intersection = clip.intersection
12501250
for dirty_region in widget._exchange_repaint_regions():
1251-
update_region = intersection(dirty_region.translate(offset))
1252-
if update_region:
1251+
if update_region := intersection(dirty_region.translate(offset)):
12531252
add_region(update_region)
12541253

12551254
self._dirty_regions.update(regions)

src/textual/_styles_cache.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
104104
Returns:
105105
Rendered lines.
106106
"""
107-
108107
border_title = widget._border_title
109108
border_subtitle = widget._border_subtitle
110109

src/textual/layouts/stream.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from itertools import zip_longest
34
from typing import TYPE_CHECKING
45

56
from textual.geometry import NULL_OFFSET, Region, Size
@@ -30,6 +31,11 @@ class StreamLayout(Layout):
3031

3132
name = "stream"
3233

34+
def __init__(self) -> None:
35+
self._cached_placements: list[WidgetPlacement] | None = None
36+
self._cached_width = 0
37+
super().__init__()
38+
3339
def arrange(
3440
self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True
3541
) -> ArrangeResult:
@@ -38,6 +44,12 @@ def arrange(
3844
return []
3945
viewport = parent.app.viewport_size
4046

47+
if size.width != self._cached_width:
48+
self._cached_placements = None
49+
previous_results = self._cached_placements or []
50+
51+
layout_widgets = parent.screen._layout_widgets.get(parent, [])
52+
4153
_Region = Region
4254
_WidgetPlacement = WidgetPlacement
4355

@@ -48,7 +60,20 @@ def arrange(
4860
previous_margin = first_child_styles.margin.top
4961
null_offset = NULL_OFFSET
5062

51-
for widget in children:
63+
pre_populate = bool(previous_results and layout_widgets)
64+
for widget, placement in zip_longest(children, previous_results):
65+
if pre_populate and placement is not None and widget is placement.widget:
66+
if widget in layout_widgets:
67+
pre_populate = False
68+
else:
69+
placements.append(placement)
70+
y = placement.region.bottom
71+
styles = widget.styles._base_styles
72+
previous_margin = styles.margin.bottom
73+
continue
74+
if widget is None:
75+
break
76+
5277
styles = widget.styles._base_styles
5378
margin = styles.margin
5479
gutter_width, gutter_height = styles.gutter.totals
@@ -85,6 +110,8 @@ def arrange(
85110
)
86111
y += height
87112

113+
self._cached_width = size.width
114+
self._cached_placements = placements
88115
return placements
89116

90117
def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int:

src/textual/messages.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def can_replace(self, message: Message) -> bool:
5252
class Layout(Message, verbose=True):
5353
"""Sent by Textual when a layout is required."""
5454

55+
def __init__(self, widget: Widget) -> None:
56+
super().__init__()
57+
self.widget = widget
58+
5559
def can_replace(self, message: Message) -> bool:
5660
return isinstance(message, Layout)
5761

src/textual/screen.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ def __init__(
321321
self._css_update_count = -1
322322
"""Track updates to CSS."""
323323

324+
self._layout_widgets: dict[DOMNode, set[Widget]] = {}
325+
"""Widgets whose layout may have changed."""
326+
324327
@property
325328
def is_modal(self) -> bool:
326329
"""Is the screen modal?"""
@@ -486,11 +489,12 @@ def active_bindings(self) -> dict[str, ActiveBinding]:
486489

487490
return bindings_map
488491

489-
def arrange(self, size: Size) -> DockArrangeResult:
492+
def arrange(self, size: Size, _optimal: bool = False) -> DockArrangeResult:
490493
"""Arrange children.
491494
492495
Args:
493496
size: Size of container.
497+
optimal: Ignored on screen.
494498
495499
Returns:
496500
Widget locations.
@@ -1270,7 +1274,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non
12701274
ResizeEvent = events.Resize
12711275

12721276
try:
1273-
if scroll:
1277+
if scroll and not self._layout_widgets:
12741278
exposed_widgets = self._compositor.reflow_visible(self, size)
12751279
if exposed_widgets:
12761280
layers = self._compositor.layers
@@ -1295,6 +1299,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non
12951299

12961300
else:
12971301
hidden, shown, resized = self._compositor.reflow(self, size)
1302+
self._layout_widgets.clear()
12981303
Hide = events.Hide
12991304
Show = events.Show
13001305

@@ -1348,8 +1353,24 @@ async def _on_update(self, message: messages.Update) -> None:
13481353
async def _on_layout(self, message: messages.Layout) -> None:
13491354
message.stop()
13501355
message.prevent_default()
1351-
self._layout_required = True
1352-
self.check_idle()
1356+
1357+
layout_required = False
1358+
widget: DOMNode = message.widget
1359+
for ancestor in message.widget.ancestors:
1360+
if not isinstance(ancestor, Widget):
1361+
break
1362+
if ancestor not in self._layout_widgets:
1363+
self._layout_widgets[ancestor] = set()
1364+
if widget not in self._layout_widgets:
1365+
self._layout_widgets[ancestor].add(widget)
1366+
layout_required = True
1367+
if not ancestor.styles.auto_dimensions:
1368+
break
1369+
widget = ancestor
1370+
1371+
if layout_required and not self._layout_required:
1372+
self._layout_required = True
1373+
self.check_idle()
13531374

13541375
async def _on_update_scroll(self, message: messages.UpdateScroll) -> None:
13551376
message.stop()

src/textual/widget.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
from textual.actions import SkipAction
6161
from textual.await_remove import AwaitRemove
6262
from textual.box_model import BoxModel
63-
from textual.cache import FIFOCache
63+
from textual.cache import FIFOCache, LRUCache
6464
from textual.color import Color
6565
from textual.compose import compose
6666
from textual.content import Content, ContentType
@@ -427,6 +427,7 @@ def __init__(
427427
self._size = _null_size
428428
self._container_size = _null_size
429429
self._layout_required = False
430+
self._layout_updates = 0
430431
self._repaint_required = False
431432
self._scroll_required = False
432433
self._recompose_required = False
@@ -455,6 +456,8 @@ def __init__(
455456
# Regions which need to be transferred from cache to screen
456457
self._repaint_regions: set[Region] = set()
457458

459+
self._box_model_cache: LRUCache[object, BoxModel] = LRUCache(16)
460+
458461
# Cache the auto content dimensions
459462
self._content_width_cache: tuple[object, int] = (None, 0)
460463
self._content_height_cache: tuple[object, int] = (None, 0)
@@ -1642,6 +1645,19 @@ def _get_box_model(
16421645
Returns:
16431646
The size and margin for this widget.
16441647
"""
1648+
cache_key = (
1649+
container,
1650+
viewport,
1651+
width_fraction,
1652+
height_fraction,
1653+
constrain_width,
1654+
greedy,
1655+
self._layout_updates,
1656+
self.styles._cache_key,
1657+
)
1658+
if cached_box_model := self._box_model_cache.get(cache_key):
1659+
return cached_box_model
1660+
16451661
styles = self.styles
16461662
is_border_box = styles.box_sizing == "border-box"
16471663
gutter = styles.gutter # Padding plus border
@@ -1750,6 +1766,7 @@ def _get_box_model(
17501766
model = BoxModel(
17511767
content_width + gutter.width, content_height + gutter.height, margin
17521768
)
1769+
self._box_model_cache[cache_key] = model
17531770
return model
17541771

17551772
def get_content_width(self, container: Size, viewport: Size) -> int:
@@ -3610,7 +3627,7 @@ def scroll_visible(
36103627
"""
36113628
parent = self.parent
36123629
if isinstance(parent, Widget):
3613-
if self.region:
3630+
if self._size:
36143631
self.screen.scroll_to_widget(
36153632
self,
36163633
animate=animate,
@@ -4200,14 +4217,9 @@ def refresh(
42004217
Returns:
42014218
The `Widget` instance.
42024219
"""
4203-
if layout:
4220+
if layout and not self._layout_required:
42044221
self._layout_required = True
4205-
for ancestor in self.ancestors:
4206-
if not isinstance(ancestor, Widget):
4207-
break
4208-
ancestor._clear_arrangement_cache()
4209-
if not ancestor.styles.auto_dimensions:
4210-
break
4222+
self._layout_updates += 1
42114223

42124224
if recompose:
42134225
self._recompose_required = True
@@ -4422,7 +4434,14 @@ def _check_refresh(self) -> None:
44224434
screen.post_message(messages.Update(self))
44234435
if self._layout_required:
44244436
self._layout_required = False
4425-
screen.post_message(messages.Layout())
4437+
for ancestor in self.ancestors:
4438+
if not isinstance(ancestor, Widget):
4439+
break
4440+
ancestor._clear_arrangement_cache()
4441+
ancestor._layout_updates += 1
4442+
if not ancestor.styles.auto_dimensions:
4443+
break
4444+
screen.post_message(messages.Layout(self))
44264445

44274446
def focus(self, scroll_visible: bool = True) -> Self:
44284447
"""Give focus to this widget.

src/textual/widgets/_button.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ class Button(Widget, can_focus=True):
228228

229229
BINDINGS = [Binding("enter", "press", "Press button", show=False)]
230230

231-
label: reactive[ContentText] = reactive[ContentText](Content.empty)
231+
label: reactive[ContentText] = reactive[ContentText](Content.empty())
232232
"""The text label that appears within the button."""
233233

234234
variant = reactive("default", init=False)
@@ -293,11 +293,12 @@ def __init__(
293293
if label is None:
294294
label = self.css_identifier_styled
295295

296-
self.label = Content.from_text(label)
297296
self.variant = variant
298-
self.action = action
299-
self.compact = compact
300297
self.flat = flat
298+
self.compact = compact
299+
self.set_reactive(Button.label, Content.from_text(label))
300+
301+
self.action = action
301302
self.active_effect_duration = 0.2
302303
"""Amount of time in seconds the button 'press' animation lasts."""
303304

tests/snapshot_tests/test_snapshots.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,9 @@ def test_remove_with_auto_height(snap_compare):
807807

808808

809809
def test_auto_table(snap_compare):
810-
assert snap_compare(SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40))
810+
assert snap_compare(
811+
SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40), press=["wait:100"]
812+
)
811813

812814

813815
def test_table_markup(snap_compare):

tests/test_box_model.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ def get_content_height(self, container: Size, parent: Size, width: int) -> int:
133133
styles.margin = 2
134134

135135
box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one)
136-
print(box_model)
137136
assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2))
138137

139138
styles.margin = 1, 2, 3, 4

0 commit comments

Comments
 (0)