From 76119a088df27edf9d04243b15412f6d8c72e66d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:43:46 -0400 Subject: [PATCH 1/4] Render damaged rows once through compositor-owned frame work --- src/textual/_compositor.py | 39 +++++++++++++------- src/textual/app.py | 5 +-- src/textual/screen.py | 73 ++++++++++++-------------------------- 3 files changed, 53 insertions(+), 64 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d0dfdb2b03..db4f6eb83e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -36,7 +36,7 @@ ) import rich.repr -from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.console import Console, ConsoleOptions, RenderResult from rich.control import Control from rich.segment import Segment from rich.style import Style @@ -169,9 +169,9 @@ def relative_bounds( class StepDeadline: - """When a resumable arrangement pauses: once `ns` (perf_counter_ns) has passed. + """When resumable compositor work pauses: once `ns` has passed. - Whoever drives the arrangement sets the deadline before each step. + Whoever drives the work sets this perf-counter deadline before each step. """ __slots__ = ("ns",) @@ -187,12 +187,12 @@ def passed(self) -> bool: def run_steps(steps: Generator[None, None, StepResult]) -> StepResult: - """Run an arrangement given no deadline: it finishes without pausing.""" + """Run compositor work without a deadline: it finishes without pausing.""" try: next(steps) except StopIteration as finished: return finished.value - raise RuntimeError("An arrangement without a deadline paused") + raise RuntimeError("Compositor work without a deadline paused") class SubtreeReflow(NamedTuple): @@ -1130,9 +1130,10 @@ def reflow_subtrees_steps( return SubtreeReflow(hidden=hidden, shown=shown, placed=placed) def _pause_before_commit(self, deadline: StepDeadline | None) -> Generator[None, None, None]: - """Give committing an arrangement (and what follows it) a step of its own - once `deadline` has passed. Until the commit, readers see the previous - scene, and lazy readers do not arrange.""" + """Pause before atomic geometry or paint work once `deadline` passes. + + While paused, lazy readers retain the published scene without arranging. + """ if deadline is not None and deadline.passed(): previous_arranging, self._arranging = self._arranging, True try: @@ -2071,17 +2072,24 @@ def render_update( self, full: bool = False, simplify: bool = False, - ) -> RenderableType | None: + *, + deadline: StepDeadline | None = None, + dirty_widgets: Iterable[Widget] = (), + ) -> Generator[None, None, CompositorUpdate | None]: """Render an update renderable. Args: full: Perform a full update if `True`, otherwise a partial update. simplify: Simplify segments. + deadline: Pause before composition when this step's budget has passed. + dirty_widgets: Pending repaint requests to consume after that pause. Returns: - A renderable for the update, or `None` if no update was required. + One publication-bound update, or `None` when nothing needs rendering. """ + yield from self._pause_before_commit(deadline) + self.update_widgets(dirty_widgets) screen_region = self.size.region if full: self._dirty_regions.add(screen_region) @@ -2093,16 +2101,23 @@ def render_inline( self, size: Size, clear: bool = False, - ) -> RenderableType | None: + *, + deadline: StepDeadline | None = None, + dirty_widgets: Iterable[Widget] = (), + ) -> Generator[None, None, InlineUpdate]: """Render an inline update. Args: size: Inline size. clear: Also clear below the inline update (set when size decreases). + deadline: Pause before composition when this step's budget has passed. + dirty_widgets: Pending repaint requests to consume after that pause. Returns: - A renderable. + One publication-bound inline update. """ + yield from self._pause_before_commit(deadline) + self.update_widgets(dirty_widgets) arranged = self._layout_map strips = self.render_strips(size) update = InlineUpdate(strips, clear=clear) diff --git a/src/textual/app.py b/src/textual/app.py index 41120a2e61..c597822244 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -76,7 +76,7 @@ from textual._ansi_theme import ALABASTER, MONOKAI from textual._callback import invoke from textual._compat import cached_property -from textual._compositor import CompositorUpdate +from textual._compositor import CompositorUpdate, run_steps from textual._context import NoActiveAppError, active_app, active_message_pump from textual._context import message_hook as message_hook_context_var from textual._dispatch_key import dispatch_key @@ -1936,7 +1936,8 @@ def export_screenshot( legacy_windows=False, safe_box=False, ) - screen_render = self.screen._compositor.render_update(full=True, simplify=simplify) + screen_render = run_steps( + self.screen._compositor.render_update(full=True, simplify=simplify)) console.print(screen_render) return console.export_svg(title=title or self.title, theme=self.ansi_theme) diff --git a/src/textual/screen.py b/src/textual/screen.py index 1d00547610..d1e739c83c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1311,39 +1311,40 @@ def hold_frames(self) -> Iterator[None]: if not self._frame_holds: self.refresh() - def _compositor_refresh(self) -> None: + def _compositor_refresh( + self, deadline: StepDeadline | None = None, + ) -> Generator[None, None, None]: """Publish the compositor's damage through the foreground screen.""" app = self.app if app._batch_count or self._frame_holds: return - if self is app.screen and app.is_inline: - inline_height = app._get_inline_height() - foreground = self is app.screen - if foreground: - # Only the screens this frame draws take part in it: a screen - # under an opaque one keeps its damage until it is shown. - drawn_behind = self._screens_seen_through - background_geometry = { - screen: screen._compositor._layout_map for screen in drawn_behind - } + if self is app.screen: if app.is_inline: + inline_height = app._get_inline_height() clear = ( app._previous_inline_height is not None and inline_height < app._previous_inline_height ) - update = self._compositor.render_inline( + update = yield from self._compositor.render_inline( app.size.with_height(inline_height), clear=clear, + deadline=deadline, dirty_widgets=self._dirty_widgets, ) - if update is not None: - app._previous_inline_height = inline_height else: - update = self._compositor.render_update() + update = yield from self._compositor.render_update( + deadline=deadline, dirty_widgets=self._dirty_widgets) + if self is not app.screen or app._batch_count or self._frame_holds: + return + # Composition is atomic after the budget pause. These are the + # backdrops just painted, not snapshots from before that pause. + drawn_behind = self._screens_seen_through if update is not None: + if app.is_inline: + app._previous_inline_height = inline_height # BackgroundScreen can render inside the foreground's renderer; # the same output update publishes the foreground's damage. ready = set(self._compositor._dirty_regions) for screen in drawn_behind: - update.bind_publication(screen._compositor, background_geometry[screen], ready) + update.bind_publication(screen._compositor, screen._compositor._layout_map, ready) app._display(self, update) if update.admitted: self._on_frame_published() @@ -1353,7 +1354,10 @@ def _compositor_refresh(self) -> None: if drawn_behind: self._release_paint() self._dirty_widgets.clear() - elif self._compositor._dirty_regions and self.is_current: + else: + self._compositor.update_widgets(self._dirty_widgets) + if not self._compositor._dirty_regions or not self.is_current: + return damage = set(self._compositor._dirty_regions) # The top screen draws this one behind itself, so it writes this # damage. Hand it the regions without consuming them. @@ -1399,14 +1403,12 @@ def _on_timer_update(self) -> None: if self._start_stepped_layout(scroll=scroll_required) is not None: return - if self._dirty_widgets: - self._compositor.update_widgets(self._dirty_widgets) if (layout_required or scroll_required or self._dirty_widgets or self._compositor._dirty_regions): # Preparation has source/follow effects even if layout left no # damage. Acquire once, after all synchronous layout effects, # rather than rendering held damage twice in the same timer. - self._compositor_refresh() + run_steps(self._compositor_refresh()) if self._recompose_required: self._recompose_required = False @@ -1708,16 +1710,8 @@ def _layout_steps( self.app._handle_exception(error) return - if deadline is not None and self.is_current: - # The damaged rows render within the steps too, so publishing - # them reads rendered rows. A layout and render that fit a step - # publish in it; a longer one publishes in a step of its own. - yield from self._render_damage_steps(deadline) if self.is_current: - # Repaints requested between the steps publish with this frame - # (publishing clears the requests). - self._compositor.update_widgets(self._dirty_widgets) - self._compositor_refresh() + yield from self._compositor_refresh(deadline) if self.app._dom_ready: self.screen_layout_refresh_signal.publish(self.screen) @@ -1725,27 +1719,6 @@ def _layout_steps( self.app.post_message(events.Ready()) self.app._dom_ready = True - def _render_damage_steps(self, deadline: StepDeadline) -> Generator[None, None, None]: - """Render the damaged visible rows of each widget, pausing between widgets once `deadline` passes. - - Each widget keeps its rendered rows (its styles cache), so the paint - that follows reads them instead of rendering. Nothing is published. - """ - compositor = self._compositor - for widget, (region, visible) in tuple(compositor.visible_widgets.items()): - damaged = [overlap for damage in compositor._dirty_regions - if (overlap := visible.intersection(damage))] - if not damaged: - continue - if deadline.passed(): - yield - if not widget.is_attached: - continue - # One render for the rows the damage spans (rows are cached whole). - top = min(overlap.y for overlap in damaged) - bottom = max(overlap.bottom for overlap in damaged) - widget.render_lines(Region(0, top - region.y, region.width, bottom - top)) - async def _on_update(self, message: messages.Update) -> None: message.stop() message.prevent_default() From 96afc8ae11c1c6b8f823c85b7abf423db94858e9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:45:52 -0400 Subject: [PATCH 2/4] Keep render contracts concise and export the update directly --- src/textual/_compositor.py | 24 ++++-------------------- src/textual/app.py | 5 ++--- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index db4f6eb83e..1c73465b7c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -2076,16 +2076,10 @@ def render_update( deadline: StepDeadline | None = None, dirty_widgets: Iterable[Widget] = (), ) -> Generator[None, None, CompositorUpdate | None]: - """Render an update renderable. + """Return one bound full/partial update after a budget pause. - Args: - full: Perform a full update if `True`, otherwise a partial update. - simplify: Simplify segments. - deadline: Pause before composition when this step's budget has passed. - dirty_widgets: Pending repaint requests to consume after that pause. - - Returns: - One publication-bound update, or `None` when nothing needs rendering. + Read pending widget damage after pausing. Composition never yields: + the returned cells and publication geometry belong to the same frame. """ yield from self._pause_before_commit(deadline) @@ -2105,17 +2099,7 @@ def render_inline( deadline: StepDeadline | None = None, dirty_widgets: Iterable[Widget] = (), ) -> Generator[None, None, InlineUpdate]: - """Render an inline update. - - Args: - size: Inline size. - clear: Also clear below the inline update (set when size decreases). - deadline: Pause before composition when this step's budget has passed. - dirty_widgets: Pending repaint requests to consume after that pause. - - Returns: - One publication-bound inline update. - """ + """Pause, then compose inline cells atomically; clear below a shrinking size.""" yield from self._pause_before_commit(deadline) self.update_widgets(dirty_widgets) arranged = self._layout_map diff --git a/src/textual/app.py b/src/textual/app.py index c597822244..50b9f0e8e2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1936,9 +1936,8 @@ def export_screenshot( legacy_windows=False, safe_box=False, ) - screen_render = run_steps( - self.screen._compositor.render_update(full=True, simplify=simplify)) - console.print(screen_render) + console.print(run_steps( + self.screen._compositor.render_update(full=True, simplify=simplify))) return console.export_svg(title=title or self.title, theme=self.ansi_theme) def save_screenshot( From eab31420adb2d97f9f8af56ae33c81592025f74c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 19:06:04 -0400 Subject: [PATCH 3/4] P21 retain typed layout instances at the style boundary --- src/textual/css/_style_properties.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 3c499095e8..8282a06755 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -634,7 +634,6 @@ def __set__(self, obj: StylesBase, layout: str | Layout | None): or a `Layout` object. """ - from textual.layouts.factory import Layout # Prevents circular import from textual.layouts.factory import MissingLayout, get_layout _rich_traceback_omit = True @@ -643,20 +642,17 @@ def __set__(self, obj: StylesBase, layout: str | Layout | None): obj.refresh(layout=True, children=True) return - if isinstance(layout, Layout): - layout = layout.name - - if obj.layout is not None and obj.layout.name == layout: + if isinstance(layout, str): + try: + layout = get_layout(layout) + except MissingLayout as error: + raise StyleValueError( + str(error), + help_text=layout_property_help_text(self.name, context="inline"), + ) + if obj.layout is layout: return - - try: - layout_object = get_layout(layout) - except MissingLayout as error: - raise StyleValueError( - str(error), - help_text=layout_property_help_text(self.name, context="inline"), - ) - if obj.set_rule("layout", layout_object): + if obj.set_rule("layout", layout): obj.refresh(layout=True, children=True) From e2d8d068972995b2447e531fb99944b3783d8269 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 20:17:48 -0400 Subject: [PATCH 4/4] P21 decode layout names only at the stylesheet boundary --- src/textual/css/_style_properties.py | 22 +------------------- tests/test_height_independent_arrangement.py | 8 +++---- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 8282a06755..438935a646 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -34,7 +34,6 @@ border_property_help_text, color_property_help_text, fractional_property_help_text, - layout_property_help_text, offset_property_help_text, scalar_help_text, spacing_wrong_number_of_values_help_text, @@ -626,30 +625,11 @@ def __set__(self, obj: StylesBase, dock_name: str): class LayoutProperty(StyleProperty['Layout | None']): """Descriptor for getting and setting layout.""" - def __set__(self, obj: StylesBase, layout: str | Layout | None): - """ - Args: - obj: The Styles object. - layout: The layout to use. You can supply the name of the layout - or a `Layout` object. - """ - - from textual.layouts.factory import MissingLayout, get_layout - - _rich_traceback_omit = True + def __set__(self, obj: StylesBase, layout: Layout | None): if layout is None: if obj.clear_rule("layout"): obj.refresh(layout=True, children=True) return - - if isinstance(layout, str): - try: - layout = get_layout(layout) - except MissingLayout as error: - raise StyleValueError( - str(error), - help_text=layout_property_help_text(self.name, context="inline"), - ) if obj.layout is layout: return if obj.set_rule("layout", layout): diff --git a/tests/test_height_independent_arrangement.py b/tests/test_height_independent_arrangement.py index c78188ec76..115bd0f29f 100644 --- a/tests/test_height_independent_arrangement.py +++ b/tests/test_height_independent_arrangement.py @@ -122,7 +122,7 @@ async def test_grid_auto_track_extrema_keep_outer_height_dependency(field, value async with app.run_test() as pilot: child = Static("wrapped text " * 10) parent = VerticalGroup(child) - parent.styles.layout = "grid" + parent.set_styles("layout: grid;") parent.styles.grid_columns = "auto" setattr(child.styles, field, value) await app.mount(parent) @@ -150,7 +150,7 @@ def get_content_height(self, container, viewport, width): child.styles.width = "1fr" child.styles.height = "auto" if unused_axis == "width" else 2 parent = VerticalGroup(child) - parent.styles.layout = "grid" + parent.set_styles("layout: grid;") parent.styles.height = "auto" parent.styles.grid_columns = "1fr" parent.styles.grid_rows = "auto" if unused_axis == "width" else "2" @@ -299,7 +299,7 @@ async def test_grid_context_rules_retain_native_arrangements(field, value): app = App() async with app.run_test() as pilot: parent = VerticalGroup(Static("text")) - parent.styles.layout = "grid" + parent.set_styles("layout: grid;") setattr(parent.styles, field, value) await app.mount(parent) await pilot.pause() @@ -327,7 +327,7 @@ def resolve(self, size, viewport, fraction_unit=Fraction(1)): layout.styles.set_rule("layout", UnknownLayout()) hook = UnknownHook(Static("text")) scalar = VerticalGroup(Static("text")) - scalar.styles.layout = "grid" + scalar.set_styles("layout: grid;") scalar.styles.set_rule("grid_rows", (UnknownScalar(1, Unit.CELLS, Unit.HEIGHT),)) await app.mount(layout, hook, scalar) await pilot.pause()