mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -53,7 +53,6 @@ def arrange(
|
||||
Returns:
|
||||
Widget arrangement information.
|
||||
"""
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
scroll_spacing = NULL_SPACING
|
||||
styles = widget.styles
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -104,7 +104,6 @@ class StylesCache:
|
||||
Returns:
|
||||
Rendered lines.
|
||||
"""
|
||||
|
||||
border_title = widget._border_title
|
||||
border_subtitle = widget._border_subtitle
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user