Merge pull request #6166 from Textualize/faster-layout

Faster layout
This commit is contained in:
Will McGugan
2025-10-08 17:14:09 +01:00
committed by GitHub
10 changed files with 95 additions and 25 deletions

View File

@@ -53,7 +53,6 @@ def arrange(
Returns:
Widget arrangement information.
"""
placements: list[WidgetPlacement] = []
scroll_spacing = NULL_SPACING
styles = widget.styles

View File

@@ -1248,8 +1248,7 @@ class Compositor:
offset = region.offset
intersection = clip.intersection
for dirty_region in widget._exchange_repaint_regions():
update_region = intersection(dirty_region.translate(offset))
if update_region:
if update_region := intersection(dirty_region.translate(offset)):
add_region(update_region)
self._dirty_regions.update(regions)

View File

@@ -104,7 +104,6 @@ class StylesCache:
Returns:
Rendered lines.
"""
border_title = widget._border_title
border_subtitle = widget._border_subtitle

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from itertools import zip_longest
from typing import TYPE_CHECKING
from textual.geometry import NULL_OFFSET, Region, Size
@@ -30,6 +31,11 @@ class StreamLayout(Layout):
name = "stream"
def __init__(self) -> None:
self._cached_placements: list[WidgetPlacement] | None = None
self._cached_width = 0
super().__init__()
def arrange(
self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True
) -> ArrangeResult:
@@ -38,6 +44,12 @@ class StreamLayout(Layout):
return []
viewport = parent.app.viewport_size
if size.width != self._cached_width:
self._cached_placements = None
previous_results = self._cached_placements or []
layout_widgets = parent.screen._layout_widgets.get(parent, [])
_Region = Region
_WidgetPlacement = WidgetPlacement
@@ -48,7 +60,20 @@ class StreamLayout(Layout):
previous_margin = first_child_styles.margin.top
null_offset = NULL_OFFSET
for widget in children:
pre_populate = bool(previous_results and layout_widgets)
for widget, placement in zip_longest(children, previous_results):
if pre_populate and placement is not None and widget is placement.widget:
if widget in layout_widgets:
pre_populate = False
else:
placements.append(placement)
y = placement.region.bottom
styles = widget.styles._base_styles
previous_margin = styles.margin.bottom
continue
if widget is None:
break
styles = widget.styles._base_styles
margin = styles.margin
gutter_width, gutter_height = styles.gutter.totals
@@ -85,6 +110,8 @@ class StreamLayout(Layout):
)
y += height
self._cached_width = size.width
self._cached_placements = placements
return placements
def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int:

View File

@@ -52,6 +52,10 @@ class Update(Message, verbose=True):
class Layout(Message, verbose=True):
"""Sent by Textual when a layout is required."""
def __init__(self, widget: Widget) -> None:
super().__init__()
self.widget = widget
def can_replace(self, message: Message) -> bool:
return isinstance(message, Layout)

View File

@@ -321,6 +321,9 @@ class Screen(Generic[ScreenResultType], Widget):
self._css_update_count = -1
"""Track updates to CSS."""
self._layout_widgets: dict[DOMNode, set[Widget]] = {}
"""Widgets whose layout may have changed."""
@property
def is_modal(self) -> bool:
"""Is the screen modal?"""
@@ -486,11 +489,12 @@ class Screen(Generic[ScreenResultType], Widget):
return bindings_map
def arrange(self, size: Size) -> DockArrangeResult:
def arrange(self, size: Size, _optimal: bool = False) -> DockArrangeResult:
"""Arrange children.
Args:
size: Size of container.
optimal: Ignored on screen.
Returns:
Widget locations.
@@ -1270,7 +1274,7 @@ class Screen(Generic[ScreenResultType], Widget):
ResizeEvent = events.Resize
try:
if scroll:
if scroll and not self._layout_widgets:
exposed_widgets = self._compositor.reflow_visible(self, size)
if exposed_widgets:
layers = self._compositor.layers
@@ -1295,6 +1299,7 @@ class Screen(Generic[ScreenResultType], Widget):
else:
hidden, shown, resized = self._compositor.reflow(self, size)
self._layout_widgets.clear()
Hide = events.Hide
Show = events.Show
@@ -1348,8 +1353,24 @@ class Screen(Generic[ScreenResultType], Widget):
async def _on_layout(self, message: messages.Layout) -> None:
message.stop()
message.prevent_default()
self._layout_required = True
self.check_idle()
layout_required = False
widget: DOMNode = message.widget
for ancestor in message.widget.ancestors:
if not isinstance(ancestor, Widget):
break
if ancestor not in self._layout_widgets:
self._layout_widgets[ancestor] = set()
if widget not in self._layout_widgets:
self._layout_widgets[ancestor].add(widget)
layout_required = True
if not ancestor.styles.auto_dimensions:
break
widget = ancestor
if layout_required and not self._layout_required:
self._layout_required = True
self.check_idle()
async def _on_update_scroll(self, message: messages.UpdateScroll) -> None:
message.stop()

View File

@@ -60,7 +60,7 @@ from textual._types import AnimationLevel
from textual.actions import SkipAction
from textual.await_remove import AwaitRemove
from textual.box_model import BoxModel
from textual.cache import FIFOCache
from textual.cache import FIFOCache, LRUCache
from textual.color import Color
from textual.compose import compose
from textual.content import Content, ContentType
@@ -427,6 +427,7 @@ class Widget(DOMNode):
self._size = _null_size
self._container_size = _null_size
self._layout_required = False
self._layout_updates = 0
self._repaint_required = False
self._scroll_required = False
self._recompose_required = False
@@ -455,6 +456,8 @@ class Widget(DOMNode):
# Regions which need to be transferred from cache to screen
self._repaint_regions: set[Region] = set()
self._box_model_cache: LRUCache[object, BoxModel] = LRUCache(16)
# Cache the auto content dimensions
self._content_width_cache: tuple[object, int] = (None, 0)
self._content_height_cache: tuple[object, int] = (None, 0)
@@ -1642,6 +1645,19 @@ class Widget(DOMNode):
Returns:
The size and margin for this widget.
"""
cache_key = (
container,
viewport,
width_fraction,
height_fraction,
constrain_width,
greedy,
self._layout_updates,
self.styles._cache_key,
)
if cached_box_model := self._box_model_cache.get(cache_key):
return cached_box_model
styles = self.styles
is_border_box = styles.box_sizing == "border-box"
gutter = styles.gutter # Padding plus border
@@ -1750,6 +1766,7 @@ class Widget(DOMNode):
model = BoxModel(
content_width + gutter.width, content_height + gutter.height, margin
)
self._box_model_cache[cache_key] = model
return model
def get_content_width(self, container: Size, viewport: Size) -> int:
@@ -3610,7 +3627,7 @@ class Widget(DOMNode):
"""
parent = self.parent
if isinstance(parent, Widget):
if self.region:
if self._size:
self.screen.scroll_to_widget(
self,
animate=animate,
@@ -4200,14 +4217,9 @@ class Widget(DOMNode):
Returns:
The `Widget` instance.
"""
if layout:
if layout and not self._layout_required:
self._layout_required = True
for ancestor in self.ancestors:
if not isinstance(ancestor, Widget):
break
ancestor._clear_arrangement_cache()
if not ancestor.styles.auto_dimensions:
break
self._layout_updates += 1
if recompose:
self._recompose_required = True
@@ -4422,7 +4434,14 @@ class Widget(DOMNode):
screen.post_message(messages.Update(self))
if self._layout_required:
self._layout_required = False
screen.post_message(messages.Layout())
for ancestor in self.ancestors:
if not isinstance(ancestor, Widget):
break
ancestor._clear_arrangement_cache()
ancestor._layout_updates += 1
if not ancestor.styles.auto_dimensions:
break
screen.post_message(messages.Layout(self))
def focus(self, scroll_visible: bool = True) -> Self:
"""Give focus to this widget.

View File

@@ -228,7 +228,7 @@ class Button(Widget, can_focus=True):
BINDINGS = [Binding("enter", "press", "Press button", show=False)]
label: reactive[ContentText] = reactive[ContentText](Content.empty)
label: reactive[ContentText] = reactive[ContentText](Content.empty())
"""The text label that appears within the button."""
variant = reactive("default", init=False)
@@ -293,11 +293,12 @@ class Button(Widget, can_focus=True):
if label is None:
label = self.css_identifier_styled
self.label = Content.from_text(label)
self.variant = variant
self.action = action
self.compact = compact
self.flat = flat
self.compact = compact
self.set_reactive(Button.label, Content.from_text(label))
self.action = action
self.active_effect_duration = 0.2
"""Amount of time in seconds the button 'press' animation lasts."""

View File

@@ -807,7 +807,9 @@ def test_remove_with_auto_height(snap_compare):
def test_auto_table(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40))
assert snap_compare(
SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40), press=["wait:100"]
)
def test_table_markup(snap_compare):

View File

@@ -133,7 +133,6 @@ def test_height():
styles.margin = 2
box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one)
print(box_model)
assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2))
styles.margin = 1, 2, 3, 4