faster layout

This commit is contained in:
Will McGugan
2025-10-06 09:21:49 +01:00
parent 08a7e26c64
commit 51dbf16d45
9 changed files with 77 additions and 34 deletions

View File

@@ -53,7 +53,6 @@ def arrange(
Returns:
Widget arrangement information.
"""
placements: list[WidgetPlacement] = []
scroll_spacing = NULL_SPACING
styles = widget.styles
@@ -65,8 +64,10 @@ def arrange(
for widgets in layers.values():
# Partition widgets into split widgets and non-split widgets
non_split_widgets, split_widgets = partition(_get_split, widgets)
if split_widgets:
_split_placements, dock_region = _arrange_split_widgets(
split_widgets, size, viewport
)

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

@@ -11,15 +11,19 @@ if TYPE_CHECKING:
from textual.geometry import Spacing
from textual.widget import Widget
from textual._profile import timer
class VerticalLayout(Layout):
"""Used to layout Widgets vertically on screen, from top to bottom."""
name = "vertical"
@timer("Vertical.arrange")
def arrange(
self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True
) -> ArrangeResult:
print(parent, size, greedy, len(children))
parent.pre_layout(self)
placements: list[WidgetPlacement] = []
add_placement = placements.append
@@ -50,15 +54,16 @@ class VerticalLayout(Layout):
else:
resolve_margin = Size(0, 0)
box_models = resolve_box_models(
[styles.height for styles in child_styles],
children,
size,
parent.app.size,
resolve_margin,
resolve_dimension="height",
greedy=greedy,
)
with timer("resolve_box_models"):
box_models = resolve_box_models(
[styles.height for styles in child_styles],
children,
size,
parent.app.size,
resolve_margin,
resolve_dimension="height",
greedy=greedy,
)
margins = [
(

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

@@ -362,6 +362,8 @@ class Reactive(Generic[ReactiveType]):
# Refresh according to descriptor flags
if self._layout or self._repaint or self._recompose:
if self._layout:
print(self, "layout", obj)
obj.refresh(
repaint=self._repaint,
layout=self._layout,

View File

@@ -321,6 +321,9 @@ class Screen(Generic[ScreenResultType], Widget):
self._css_update_count = -1
"""Track updates to CSS."""
self._layout_widgets: set[Widget] = set()
"""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,10 @@ 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()
if message.widget not in self._layout_widgets:
self._layout_widgets.add(message.widget)
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,
greedy,
constrain_width,
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:
@@ -3999,10 +4016,10 @@ class Widget(DOMNode):
if self._size != size:
self._set_dirty()
self._size = size
if layout:
self.virtual_size = virtual_size
else:
self.set_reactive(Widget.virtual_size, virtual_size)
# if layout:
# self.virtual_size = virtual_size
# else:
self.set_reactive(Widget.virtual_size, virtual_size)
self._container_size = container_size
if self.is_scrollable:
self._scroll_update(virtual_size)
@@ -4200,14 +4217,15 @@ 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
# for ancestor in self.ancestors:
# if not isinstance(ancestor, Widget):
# break
# ancestor._clear_arrangement_cache()
# if not ancestor.styles.auto_dimensions:
# break
if recompose:
self._recompose_required = True
@@ -4422,7 +4440,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

@@ -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