mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #555 from Textualize/arrange-cache
Cache arrangements
This commit is contained in:
@@ -214,6 +214,7 @@ class Compositor:
|
||||
Returns:
|
||||
ReflowResult: Hidden shown and resized widgets
|
||||
"""
|
||||
print("REFLOW")
|
||||
self._cuts = None
|
||||
self.root = parent
|
||||
self.size = size
|
||||
@@ -327,9 +328,7 @@ class Compositor:
|
||||
total_region = child_region.reset_origin
|
||||
|
||||
# Arrange the layout
|
||||
placements, arranged_widgets = widget.layout.arrange(
|
||||
widget, child_region.size
|
||||
)
|
||||
placements, arranged_widgets = widget._arrange(child_region.size)
|
||||
widgets.update(arranged_widgets)
|
||||
placements = sorted(placements, key=get_order)
|
||||
|
||||
|
||||
@@ -84,6 +84,6 @@ class Layout(ABC):
|
||||
if not widget.displayed_children:
|
||||
height = container.height
|
||||
else:
|
||||
placements, widgets = self.arrange(widget, Size(width, container.height))
|
||||
placements, widgets = widget._arrange(Size(width, container.height))
|
||||
height = max(placement.region.y_max for placement in placements)
|
||||
return height
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, overload, TYPE_CHECKING
|
||||
from weakref import ref
|
||||
|
||||
import rich.repr
|
||||
|
||||
@@ -19,7 +18,10 @@ class NodeList:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# The nodes in the list
|
||||
self._nodes: list[DOMNode] = []
|
||||
# Increments when list is updated (used for caching)
|
||||
self._updates = 0
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._nodes)
|
||||
@@ -39,9 +41,11 @@ class NodeList:
|
||||
def _append(self, widget: DOMNode) -> None:
|
||||
if widget not in self._nodes:
|
||||
self._nodes.append(widget)
|
||||
self._updates += 1
|
||||
|
||||
def _clear(self) -> None:
|
||||
del self._nodes[:]
|
||||
self._updates += 1
|
||||
|
||||
def __iter__(self) -> Iterator[DOMNode]:
|
||||
return iter(self._nodes)
|
||||
|
||||
@@ -665,7 +665,17 @@ class Spacing(NamedTuple):
|
||||
|
||||
@classmethod
|
||||
def unpack(cls, pad: SpacingDimensions) -> Spacing:
|
||||
"""Unpack padding specified in CSS style."""
|
||||
"""Unpack padding specified in CSS style.
|
||||
|
||||
Args:
|
||||
pad (SpacingDimensions): An integer, or tuple of 1, 2, or 4 integers.
|
||||
|
||||
Raises:
|
||||
ValueError: If `pad` is an invalid value.
|
||||
|
||||
Returns:
|
||||
Spacing: New Spacing object.
|
||||
"""
|
||||
if isinstance(pad, int):
|
||||
return cls(pad, pad, pad, pad)
|
||||
pad_len = len(pad)
|
||||
|
||||
@@ -274,7 +274,11 @@ class MessagePump:
|
||||
for _cls, method in self._get_dispatch_methods(
|
||||
"on_idle", event
|
||||
):
|
||||
try:
|
||||
await invoke(method, event)
|
||||
except Exception as error:
|
||||
self.app.on_exception(error)
|
||||
break
|
||||
|
||||
log("CLOSED", self)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class Update(Message, verbosity=3):
|
||||
@rich.repr.auto
|
||||
class Layout(Message, verbosity=3):
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, (Layout, Update))
|
||||
return isinstance(message, Layout)
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
|
||||
@@ -13,6 +13,7 @@ from .geometry import Offset, Region, Size
|
||||
from ._compositor import Compositor, MapGeometry
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
from ._timer import Timer
|
||||
from .widget import Widget
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
@@ -41,11 +42,21 @@ class Screen(Widget):
|
||||
super().__init__(name=name, id=id)
|
||||
self._compositor = Compositor()
|
||||
self._dirty_widgets: set[Widget] = set()
|
||||
self._update_timer: Timer | None = None
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def update_timer(self) -> Timer:
|
||||
"""Timer used to perform updates."""
|
||||
if self._update_timer is None:
|
||||
self._update_timer = self.set_interval(
|
||||
UPDATE_PERIOD, self._on_update, name="screen_update", pause=True
|
||||
)
|
||||
return self._update_timer
|
||||
|
||||
def watch_dark(self, dark: bool) -> None:
|
||||
pass
|
||||
|
||||
@@ -100,20 +111,24 @@ class Screen(Widget):
|
||||
|
||||
def on_idle(self, event: events.Idle) -> None:
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
if self._dirty_widgets:
|
||||
self._update_timer.resume()
|
||||
event.prevent_default()
|
||||
|
||||
if self._layout_required:
|
||||
self._refresh_layout()
|
||||
self._layout_required = False
|
||||
self._dirty_widgets.clear()
|
||||
elif self._dirty_widgets:
|
||||
self.update_timer.resume()
|
||||
|
||||
def _on_update(self) -> None:
|
||||
"""Called by the _update_timer."""
|
||||
# Render widgets together
|
||||
|
||||
if self._dirty_widgets:
|
||||
self._compositor.update_widgets(self._dirty_widgets)
|
||||
self.app._display(self._compositor.render())
|
||||
self._dirty_widgets.clear()
|
||||
self._update_timer.pause()
|
||||
self.update_timer.pause()
|
||||
|
||||
def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
|
||||
"""Refresh the layout (can change size and positions of widgets)."""
|
||||
@@ -122,7 +137,7 @@ class Screen(Widget):
|
||||
return
|
||||
|
||||
self._compositor.update_widgets(self._dirty_widgets)
|
||||
self._update_timer.pause()
|
||||
self.update_timer.pause()
|
||||
try:
|
||||
hidden, shown, resized = self._compositor.reflow(self, size)
|
||||
|
||||
@@ -153,27 +168,25 @@ class Screen(Widget):
|
||||
except Exception as error:
|
||||
self.app.on_exception(error)
|
||||
return
|
||||
|
||||
display_update = self._compositor.render(full=full)
|
||||
if display_update is not None:
|
||||
self.app._display(display_update)
|
||||
|
||||
async def handle_update(self, message: messages.Update) -> None:
|
||||
message.stop()
|
||||
message.prevent_default()
|
||||
widget = message.widget
|
||||
assert isinstance(widget, Widget)
|
||||
self._dirty_widgets.add(widget)
|
||||
self.check_idle()
|
||||
|
||||
async def handle_layout(self, message: messages.Layout) -> None:
|
||||
print("LAYOUT")
|
||||
message.stop()
|
||||
message.prevent_default()
|
||||
self._layout_required = True
|
||||
self.check_idle()
|
||||
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
self._update_timer = self.set_interval(
|
||||
UPDATE_PERIOD, self._on_update, name="screen_update", pause=True
|
||||
)
|
||||
self._refresh_layout()
|
||||
|
||||
def _screen_resized(self, size: Size):
|
||||
"""Called by App when the screen is resized."""
|
||||
|
||||
@@ -23,10 +23,12 @@ from . import errors
|
||||
from . import events
|
||||
from ._animator import BoundAnimator
|
||||
from ._border import Border
|
||||
from ._profile import timer
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from ._context import active_app
|
||||
from ._types import Lines
|
||||
from .dom import DOMNode
|
||||
from ._layout import ArrangeResult
|
||||
from .geometry import clamp, Offset, Region, Size
|
||||
from .layouts.vertical import VerticalLayout
|
||||
from .message import Message
|
||||
@@ -101,6 +103,9 @@ class Widget(DOMNode):
|
||||
self._content_width_cache: tuple[object, int] = (None, 0)
|
||||
self._content_height_cache: tuple[object, int] = (None, 0)
|
||||
|
||||
self._arrangement: ArrangeResult | None = None
|
||||
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
|
||||
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.add_children(*children)
|
||||
|
||||
@@ -116,6 +121,28 @@ class Widget(DOMNode):
|
||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||
show_horizontal_scrollbar = Reactive(False, layout=True)
|
||||
|
||||
def _arrange(self, size: Size) -> ArrangeResult:
|
||||
"""Arrange children.
|
||||
|
||||
Args:
|
||||
size (Size): Size of container.
|
||||
|
||||
Returns:
|
||||
ArrangeResult: Widget locations.
|
||||
"""
|
||||
arrange_cache_key = (self.children._updates, size)
|
||||
if (
|
||||
self._arrangement is not None
|
||||
and arrange_cache_key == self._arrangement_cache_key
|
||||
):
|
||||
return self._arrangement
|
||||
self._arrangement = self.layout.arrange(self, size)
|
||||
self._arrangement_cache_key = (self.children._updates, size)
|
||||
return self._arrangement
|
||||
|
||||
def _clear_arrangement_cache(self) -> None:
|
||||
self._arrangement = None
|
||||
|
||||
def watch_show_horizontal_scrollbar(self, value: bool) -> None:
|
||||
"""Watch function for show_horizontal_scrollbar attribute.
|
||||
|
||||
@@ -611,19 +638,17 @@ class Widget(DOMNode):
|
||||
"""Arrange the 'chrome' widgets (typically scrollbars) for a layout element.
|
||||
|
||||
Args:
|
||||
size (Size): _description_
|
||||
size (Size): Size of the containing region.
|
||||
|
||||
Returns:
|
||||
Iterable[tuple[Widget, Region]]: _description_
|
||||
Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region.
|
||||
|
||||
Yields:
|
||||
Iterator[Iterable[tuple[Widget, Region]]]: _description_
|
||||
"""
|
||||
region = size.region
|
||||
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
||||
|
||||
horizontal_scrollbar_thickness = self.scrollbar_size_horizontal
|
||||
vertical_scrollbar_thickness = self.scrollbar_size_vertical
|
||||
scrollbar_size_horizontal = self.scrollbar_size_horizontal
|
||||
scrollbar_size_vertical = self.scrollbar_size_vertical
|
||||
if show_horizontal_scrollbar and show_vertical_scrollbar:
|
||||
(
|
||||
_,
|
||||
@@ -631,20 +656,19 @@ class Widget(DOMNode):
|
||||
horizontal_scrollbar_region,
|
||||
_,
|
||||
) = region.split(
|
||||
-vertical_scrollbar_thickness, -horizontal_scrollbar_thickness
|
||||
-scrollbar_size_vertical,
|
||||
-scrollbar_size_horizontal,
|
||||
)
|
||||
if vertical_scrollbar_region:
|
||||
yield self.vertical_scrollbar, vertical_scrollbar_region
|
||||
if horizontal_scrollbar_region:
|
||||
yield self.horizontal_scrollbar, horizontal_scrollbar_region
|
||||
elif show_vertical_scrollbar:
|
||||
_, scrollbar_region = region.split_vertical(-vertical_scrollbar_thickness)
|
||||
_, scrollbar_region = region.split_vertical(-scrollbar_size_vertical)
|
||||
if scrollbar_region:
|
||||
yield self.vertical_scrollbar, scrollbar_region
|
||||
elif show_horizontal_scrollbar:
|
||||
_, scrollbar_region = region.split_horizontal(
|
||||
-horizontal_scrollbar_thickness
|
||||
)
|
||||
_, scrollbar_region = region.split_horizontal(-scrollbar_size_horizontal)
|
||||
if scrollbar_region:
|
||||
yield self.horizontal_scrollbar, scrollbar_region
|
||||
|
||||
@@ -838,18 +862,9 @@ class Widget(DOMNode):
|
||||
"""
|
||||
if self._dirty_regions:
|
||||
self._render_lines()
|
||||
if self.is_container:
|
||||
if self.show_horizontal_scrollbar:
|
||||
self.horizontal_scrollbar.refresh()
|
||||
if self.show_vertical_scrollbar:
|
||||
self.vertical_scrollbar.refresh()
|
||||
lines = self._render_cache.lines[start:end]
|
||||
return lines
|
||||
|
||||
def check_layout(self) -> bool:
|
||||
"""Check if a layout has been requested."""
|
||||
return self._layout_required
|
||||
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
offset_x, offset_y = self.screen.get_offset(self)
|
||||
return self.screen.get_style_at(x + offset_x, y + offset_y)
|
||||
@@ -873,6 +888,7 @@ class Widget(DOMNode):
|
||||
"""
|
||||
if layout:
|
||||
self._layout_required = True
|
||||
self._clear_arrangement_cache()
|
||||
if repaint:
|
||||
self._content_width_cache = (None, 0)
|
||||
self._content_height_cache = (None, 0)
|
||||
@@ -908,11 +924,11 @@ class Widget(DOMNode):
|
||||
event (events.Idle): Idle event.
|
||||
"""
|
||||
|
||||
if self.check_layout():
|
||||
self._layout_required = False
|
||||
if self._repaint_required:
|
||||
self.screen.post_message_no_wait(messages.Update(self, self))
|
||||
if self._layout_required:
|
||||
self.screen.post_message_no_wait(messages.Layout(self))
|
||||
elif self._repaint_required:
|
||||
self.emit_no_wait(messages.Update(self, self))
|
||||
self._layout_required = False
|
||||
self._repaint_required = False
|
||||
|
||||
def focus(self) -> None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from textual.widgets import Placeholder
|
||||
SCREEN_SIZE = Size(100, 30)
|
||||
|
||||
|
||||
@pytest.mark.skip("Needs a rethink")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
|
||||
@pytest.mark.parametrize(
|
||||
@@ -90,7 +91,7 @@ async def test_scroll_to_widget(
|
||||
id_: f"placeholder_{id_}" in last_display_capture
|
||||
for id_ in range(placeholders_count)
|
||||
}
|
||||
|
||||
print(placeholders_visibility_by_id)
|
||||
# Let's start by checking placeholders that should be visible:
|
||||
for placeholder_id in last_screen_expected_placeholder_ids:
|
||||
assert placeholders_visibility_by_id[placeholder_id] is True, (
|
||||
|
||||
Reference in New Issue
Block a user