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
53 changes: 26 additions & 27 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -170,9 +170,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",)
Expand All @@ -188,12 +188,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):
Expand Down Expand Up @@ -1131,9 +1131,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:
Expand Down Expand Up @@ -2072,17 +2073,18 @@ def render_update(
self,
full: bool = False,
simplify: bool = False,
) -> RenderableType | None:
"""Render an update renderable.

Args:
full: Perform a full update if `True`, otherwise a partial update.
simplify: Simplify segments.

Returns:
A renderable for the update, or `None` if no update was required.
*,
deadline: StepDeadline | None = None,
dirty_widgets: Iterable[Widget] = (),
) -> Generator[None, None, CompositorUpdate | None]:
"""Return one bound full/partial update after a budget pause.

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)
self.update_widgets(dirty_widgets)
screen_region = self.size.region
if full:
self._dirty_regions.add(screen_region)
Expand All @@ -2094,16 +2096,13 @@ def render_inline(
self,
size: Size,
clear: bool = False,
) -> RenderableType | None:
"""Render an inline update.

Args:
size: Inline size.
clear: Also clear below the inline update (set when size decreases).

Returns:
A renderable.
"""
*,
deadline: StepDeadline | None = None,
dirty_widgets: Iterable[Widget] = (),
) -> Generator[None, None, InlineUpdate]:
"""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
strips = self.render_strips(size)
update = InlineUpdate(strips, clear=clear)
Expand Down
6 changes: 3 additions & 3 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1936,8 +1936,8 @@ def export_screenshot(
legacy_windows=False,
safe_box=False,
)
screen_render = 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(
Expand Down
30 changes: 3 additions & 27 deletions src/textual/css/_style_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -626,37 +625,14 @@ 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 Layout # Prevents circular import
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, Layout):
layout = layout.name

if obj.layout is not None and obj.layout.name == layout:
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)


Expand Down
73 changes: 23 additions & 50 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1708,44 +1710,15 @@ 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)
else:
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()
Expand Down
8 changes: 4 additions & 4 deletions tests/test_height_independent_arrangement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down