Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/textual/_arrange.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ def arrange(
Returns:
Widget arrangement information.
"""

placements: list[WidgetPlacement] = []
scroll_spacing = NULL_SPACING
styles = widget.styles
Expand Down
3 changes: 1 addition & 2 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1248,8 +1248,7 @@ def update_widgets(self, widgets: set[Widget]) -> None:
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)
1 change: 0 additions & 1 deletion src/textual/_styles_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
Returns:
Rendered lines.
"""

border_title = widget._border_title
border_subtitle = widget._border_subtitle

Expand Down
29 changes: 28 additions & 1 deletion src/textual/layouts/stream.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -38,6 +44,12 @@ def arrange(
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

Expand All @@ -48,7 +60,20 @@ def arrange(
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
Expand Down Expand Up @@ -85,6 +110,8 @@ def arrange(
)
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:
Expand Down
4 changes: 4 additions & 0 deletions src/textual/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ def can_replace(self, message: Message) -> bool:
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)

Expand Down
29 changes: 25 additions & 4 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ def __init__(
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?"""
Expand Down Expand Up @@ -486,11 +489,12 @@ def active_bindings(self) -> dict[str, ActiveBinding]:

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.
Expand Down Expand Up @@ -1270,7 +1274,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non
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
Expand All @@ -1295,6 +1299,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non

else:
hidden, shown, resized = self._compositor.reflow(self, size)
self._layout_widgets.clear()
Hide = events.Hide
Show = events.Show

Expand Down Expand Up @@ -1348,8 +1353,24 @@ async def _on_update(self, message: messages.Update) -> None:
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()
Expand Down
39 changes: 29 additions & 10 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
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
Expand Down Expand Up @@ -427,6 +427,7 @@ def __init__(
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
Expand Down Expand Up @@ -455,6 +456,8 @@ def __init__(
# 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)
Expand Down Expand Up @@ -1642,6 +1645,19 @@ def _get_box_model(
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
Expand Down Expand Up @@ -1750,6 +1766,7 @@ def _get_box_model(
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:
Expand Down Expand Up @@ -3610,7 +3627,7 @@ def scroll_visible(
"""
parent = self.parent
if isinstance(parent, Widget):
if self.region:
if self._size:
self.screen.scroll_to_widget(
self,
animate=animate,
Expand Down Expand Up @@ -4200,14 +4217,9 @@ def refresh(
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
Expand Down Expand Up @@ -4422,7 +4434,14 @@ def _check_refresh(self) -> None:
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.
Expand Down
9 changes: 5 additions & 4 deletions src/textual/widgets/_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -293,11 +293,12 @@ def __init__(
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."""

Expand Down
4 changes: 3 additions & 1 deletion tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 0 additions & 1 deletion tests/test_box_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ def get_content_height(self, container: Size, parent: Size, width: int) -> int:
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
Expand Down
Loading