diff --git a/.github/workflows/black_format.yml b/.github/workflows/black_format.yml deleted file mode 100644 index 0be0eb712ca..00000000000 --- a/.github/workflows/black_format.yml +++ /dev/null @@ -1,22 +0,0 @@ - -name: Black format check - -on: - pull_request: - paths: - - '.github/workflows/black_format.yml' - - '**.py' - -jobs: - black-format-check: - runs-on: "ubuntu-latest" - steps: - - uses: actions/checkout@v3.5.2 - - name: Set up Python 3.11 - uses: actions/setup-python@v4.6.0 - with: - python-version: 3.11 - - name: Install black - run: python -m pip install black==24.1.1 - - name: Run black - run: black --check src diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml deleted file mode 100644 index be11467e2b0..00000000000 --- a/.github/workflows/debt-ratchet.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Debt ratchet - -on: - pull_request: - branches: [main] - -permissions: - contents: read - -concurrency: - group: debt-ratchet-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - debt-ratchet: - name: Debt ratchet - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - name: Install the packaged ratchet pinned by Toad main - # Same Core source as Toad's tool.uv.sources.agent-comms.rev. - run: python -m pip install --disable-pip-version-check 'agent-comms @ git+https://github.com/OpenHCSDev/agent-comms.git@88f4e6ee115ba817118ffcc0b52e5579eb7763e5' - - name: Reject increases in production debt - env: - PR_BASE: ${{ github.event.pull_request.base.sha }} - PR_HEAD: ${{ github.event.pull_request.head.sha }} - run: agent-comms-ratchet --root src/textual --base "$PR_BASE" --head "$PR_HEAD" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 407ad33b4f8..9184c2cf032 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] defaults: run: shell: bash @@ -35,18 +35,9 @@ jobs: cache: "poetry" - name: Install dependencies run: poetry install --no-interaction --extras syntax - if: ${{ matrix.python-version != '3.9' }} - - name: Install dependencies for 3.9 - run: poetry install --no-interaction - if: ${{ matrix.python-version == '3.9' }} - - name: Test with pytest (Py310+ - with syntax highlighting) + - name: Test with pytest run: | poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing - if: ${{ matrix.python-version != '3.9' }} - - name: Test with pytest (Py39 - without syntax highlighting) - run: | - poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing -m 'not syntax' - if: ${{ matrix.python-version == '3.9' }} - name: Upload snapshot report if: always() uses: actions/upload-artifact@v4 diff --git a/docs/examples/guide/widgets/checker01.py b/docs/examples/guide/widgets/checker01.py index 66b6a096323..77fdf0eb983 100644 --- a/docs/examples/guide/widgets/checker01.py +++ b/docs/examples/guide/widgets/checker01.py @@ -4,12 +4,13 @@ from textual.app import App, ComposeResult from textual.strip import Strip from textual.widget import Widget +from textual._paint_state import PaintState class CheckerBoard(Widget): """Render an 8x8 checkerboard.""" - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: """Render a line of the widget. y is relative to the top of the widget.""" row_index = y // 4 # A checkerboard square consists of 4 rows diff --git a/docs/examples/guide/widgets/checker02.py b/docs/examples/guide/widgets/checker02.py index b498ef204a7..d3e982bd9d3 100644 --- a/docs/examples/guide/widgets/checker02.py +++ b/docs/examples/guide/widgets/checker02.py @@ -3,6 +3,7 @@ from textual.app import App, ComposeResult from textual.strip import Strip from textual.widget import Widget +from textual._paint_state import PaintState class CheckerBoard(Widget): @@ -22,7 +23,7 @@ class CheckerBoard(Widget): } """ - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: """Render a line of the widget. y is relative to the top of the widget.""" row_index = y // 4 # four lines per row diff --git a/docs/examples/guide/widgets/checker03.py b/docs/examples/guide/widgets/checker03.py index 1792968c714..520c713e2e3 100644 --- a/docs/examples/guide/widgets/checker03.py +++ b/docs/examples/guide/widgets/checker03.py @@ -6,6 +6,7 @@ from textual.scroll_view import ScrollView from rich.segment import Segment +from textual._paint_state import PaintState class CheckerBoard(ScrollView): @@ -29,7 +30,7 @@ def __init__(self, board_size: int) -> None: # Each square is 4 rows and 8 columns self.virtual_size = Size(board_size * 8, board_size * 4) - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: """Render a line of the widget. y is relative to the top of the widget.""" scroll_x, scroll_y = self.scroll_offset # The current scroll position diff --git a/docs/examples/guide/widgets/checker04.py b/docs/examples/guide/widgets/checker04.py index 0445ffea5a2..baf701e3c55 100644 --- a/docs/examples/guide/widgets/checker04.py +++ b/docs/examples/guide/widgets/checker04.py @@ -9,6 +9,7 @@ from rich.segment import Segment from rich.style import Style +from textual._paint_state import PaintState class CheckerBoard(ScrollView): @@ -62,7 +63,7 @@ def get_square_region(square_offset: Offset) -> Region: # Refresh the new cursor square self.refresh(get_square_region(cursor_square)) - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: """Render a line of the widget. y is relative to the top of the widget.""" scroll_x, scroll_y = self.scroll_offset # The current scroll position diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md index cdbe3e8b169..15734a858b7 100644 --- a/docs/guide/widgets.md +++ b/docs/guide/widgets.md @@ -404,7 +404,7 @@ Textual offers an alternative API which reduces the amount of work required to r ### Render Line method -To build a widget with the line API, implement a `render_line` method rather than a `render` method. The `render_line` method takes a single integer argument `y` which is an offset from the top of the widget, and should return a [Strip][textual.strip.Strip] object containing that line's content. +To build a widget with the line API, implement a `render_line` method rather than a `render` method. The `render_line` method takes an integer `y`, which is an offset from the top of the widget, and the widget's resolved `paint` state (its colors, text style and opacity for this frame), and should return a [Strip][textual.strip.Strip] object containing that line's content. Textual will call this method as required to get content for every row of characters in the widget.
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index c4849cae936..5656ba745b7 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -412,9 +412,20 @@ def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: ) return spatial_map - @abstractmethod def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool: - ... + """The same arrangement, or a complete one serving a viewport request.""" + if require_complete and not self.complete: + return False + mine, wanted = self._comparable(self.key), self._comparable(key) + return mine == wanted or ( + not mine.visible_only and wanted.visible_only + and mine._replace(visible_only=True) == wanted + ) + + @staticmethod + @abstractmethod + def _comparable(key: SubtreeGeometryKey) -> SubtreeGeometryKey: + """The inputs this resource's placements depend on.""" def restore_into( self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget], @@ -515,13 +526,9 @@ def references_retired(self, owner: Widget, retired: set[Widget]) -> bool: class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]): """A culled or screen-dependent arrangement keeps its exact placement.""" - def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool: - if require_complete and not self.complete: - return False - return self.key == key or ( - not self.key.visible_only and key.visible_only - and self.key._replace(visible_only=True) == key - ) + @staticmethod + def _comparable(key: SubtreeGeometryKey) -> SubtreeGeometryKey: + return key def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: @@ -560,10 +567,9 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ return super().capture(key, intrinsic, widgets, invisible_widgets, clips, clip, screen_coordinates) - def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool: - if require_complete and not self.complete: - return False - return self.key.intrinsic() == key.intrinsic() + @staticmethod + def _comparable(key: SubtreeGeometryKey) -> SubtreeGeometryKey: + return key.intrinsic() def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: diff --git a/src/textual/app.py b/src/textual/app.py index 47f2df6be26..0774301b2d6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2940,6 +2940,10 @@ def _load_screen_css(self, screen: Screen): if update: self.stylesheet.reparse() self.stylesheet.update(self) + # The screen was mounted, and styled, before its own CSS was read, + # and a pushed screen is not on a stack yet for the source change to + # queue it: this screen is the one the new rules are for. + self.stylesheet.update(screen) async def _replace_screen(self, screen: Screen) -> Screen: """Handle the replaced screen. @@ -3649,13 +3653,16 @@ async def _on_compose(self) -> None: _rich_traceback_omit = True self._compose_screen = self.screen try: - widgets = [*self.screen._nodes, *await compose_async(self)] + widgets = await compose_async(self) except TypeError as error: raise TypeError( f"{self!r} compose() method returned an invalid result; {error}" ) from error - await self.mount_all(widgets) + # The screen's own widgets (tooltip, toasts) are already mounted. The + # app's widgets go in front of them, so the app's queries, such as + # query_one(Static) in on_mount, find its own widgets first. + await self.mount_all(widgets, before=0 if self.screen._nodes else None) # The screen resumed before these widgets existed, so its own # auto-focus pass found nothing to focus. self._compose_screen._update_auto_focus() diff --git a/src/textual/binding.py b/src/textual/binding.py index 56e210001ef..d61e2b21e61 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -128,9 +128,18 @@ def command_invocations(self) -> tuple[KeyInvocation, ...]: def invocations(self) -> tuple[KeyInvocation, ...]: return self.declared_key.invocations(self.command_invocations()) + def shared_identity(self, mode: type[EditingMode]) -> str | None: + """The arrow preference this binding's key shares in the mode, if its key is an arrow.""" + return next((invocation.binding_identity(mode) for invocation in self.invocations()), self.id) + + def preference_identity(self, mode: type[EditingMode]) -> str | None: + """A named action keeps the id its author declared; without one it joins its arrow's + shared preference, so a keymap for that id replaces every key the author listed.""" + return self.id if self.id is not None else self.shared_identity(mode) + def for_mode(self, mode: type[EditingMode]) -> tuple[Self, ...]: """Give native arrows their shared preference identity in the actual mode.""" - identity = next((invocation.binding_identity(mode) for invocation in self.invocations()), self.id) + identity = self.preference_identity(mode) return tuple( dataclasses.replace(self, key=spelling, id=identity) for spelling in self.declared_key.spellings(mode, self.key) @@ -293,6 +302,10 @@ def invocation(self) -> KeyInvocation: def command_invocations(self) -> tuple[KeyInvocation, ...]: return (self.invocation,) + def preference_identity(self, mode: type[EditingMode]) -> str | None: + """Native commands on an arrow always share its preference, whatever id they carry.""" + return self.shared_identity(mode) + def key_identity(self) -> str: return self.qualified(self.action) diff --git a/src/textual/containers.py b/src/textual/containers.py index 61b3c410d31..8ff33bbb933 100644 --- a/src/textual/containers.py +++ b/src/textual/containers.py @@ -7,6 +7,7 @@ from __future__ import annotations +from textual.layouts.vertical import VerticalLayout from textual.layout import Layout from textual.navigation import ScrollCommand from textual.layouts.grid import GridLayout @@ -18,6 +19,8 @@ class Container(Widget): """Simple container widget, with vertical layout.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ Container { width: 1fr; @@ -30,6 +33,8 @@ class Container(Widget): class ScrollableContainer(Widget, can_focus=True, key_families=(ScrollCommand,)): """A scrollable container with vertical layout, and auto scrollbars on both axis.""" + DEFAULT_LAYOUT = VerticalLayout + # We don't typically want to maximize scrollable containers, # since the user can easily navigate the contents ALLOW_MAXIMIZE = False @@ -90,6 +95,8 @@ def allow_maximize(self) -> bool: class Vertical(Widget): """An expanding container with vertical layout and no scrollbars.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ Vertical { width: 1fr; @@ -102,6 +109,8 @@ class Vertical(Widget): class VerticalGroup(Widget): """A non-expanding container with vertical layout and no scrollbars.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ VerticalGroup { width: 1fr; @@ -166,6 +175,8 @@ class HorizontalScroll(ScrollableContainer): class Center(Widget): """A container which aligns children on the X axis.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ Center { align-horizontal: center; @@ -178,6 +189,8 @@ class Center(Widget): class Right(Widget): """A container which aligns children on the X axis.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ Right { align-horizontal: right; @@ -190,6 +203,8 @@ class Right(Widget): class Middle(Widget): """A container which aligns children on the Y axis.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ Middle { align-vertical: middle; @@ -202,6 +217,8 @@ class Middle(Widget): class CenterMiddle(Widget): """A container which aligns its children on both axis.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ CenterMiddle { align: center middle; diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index abab35f4732..98545681781 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -86,8 +86,15 @@ def __get__(self, obj: None, objtype: type[StylesBase] | None = None) -> Self: . def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> PropertyGetType: ... def __get__(self, obj: StylesBase | None, objtype: type[StylesBase] | None = None): - """Read the rule, or the declared default when it is unset.""" - return self if obj is None else obj.get_rule(self.name, self.default) + """Read the rule, or the declared default when it is unset or `initial`. + + The builder stores `initial` as None so it still overrides less specific + rules; reading it answers the default, never None. + """ + if obj is None: + return self + value = obj.get_rule(self.name) + return self.default if value is None else value def __set_name__(self, owner: type[StylesBase], name: str) -> None: self.name = name diff --git a/src/textual/dom.py b/src/textual/dom.py index bab28bd6b9a..d942378bd6a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -352,7 +352,7 @@ def _render_container(self): """Native container paint, shared independently of scene custody.""" from textual.renderables.blank import Blank - if self.geometry.child_layout and self.geometry.keyline[0] != "none": + if self.is_container and self.geometry.keyline[0] != "none": return self.layout.render_keyline(self) return Blank(self.background_colors[1]) diff --git a/src/textual/layout.py b/src/textual/layout.py index 5d2f72b17d2..9d2f2eaaab2 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -259,6 +259,8 @@ class Layout(ABC): """Base class of the object responsible for arranging Widgets within a container.""" name: ClassVar[str] = "" + holds_children: ClassVar[bool] = True + """A declared layout makes its widget a container, with or without children.""" _content_width_dependency: ClassVar[HeightDependency] _content_height_dependency: ClassVar[HeightDependency] _arrangement_height_dependency: ClassVar[HeightDependency] diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index b7a0b2e87b9..43e7701c77f 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -118,3 +118,10 @@ def arrange( y = next_y + margin return placements + + +class ImplicitVerticalLayout(VerticalLayout): + """The layout of a widget that declares none: it stacks any children it is + given, but does not make the widget a container.""" + + holds_children = False diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index b869d9bd5c9..2d960b4a901 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1045,8 +1045,10 @@ async def _on_message(self, message: Message) -> None: await invoke(method, message) if not dispatched: log.event.verbosity(message.verbose)(message, ">>>", self, "method=None") + self._bubble_onward(message) - # Bubble messages up the DOM (if enabled on the message) + def _bubble_onward(self, message: Message) -> None: + """Pass a bubbling message to the parent, if this node's DOM still links it there.""" if message.bubble and self._parent and not message._stop_propagation: if message._sender is not None and message._sender == self._parent: # parent is sender, so we stop propagation after parent diff --git a/src/textual/navigation.py b/src/textual/navigation.py index 2823aa607fd..07b61abf914 100644 --- a/src/textual/navigation.py +++ b/src/textual/navigation.py @@ -15,7 +15,6 @@ class NavigationCommand(KeyDeclaration["Navigation"]): """A list command owns its keys; controls own the operation it requests.""" keys: ClassVar[tuple[str, ...]] = () - priority: ClassVar[bool] = False declared_key: ClassVar[DeclaredKey] = UndecodedKey() @classmethod @@ -26,7 +25,7 @@ def bindings_for(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return tuple( CommandBinding(key=key, action=command, description=cls.title, available=cls.available, mode=EditingMode, - id=cls.binding_id(mode), show=False, priority=cls.priority, + id=cls.binding_id(mode), show=False, declared_key=cls.declared_key) for key in cls.keys ) @@ -43,9 +42,13 @@ def apply(self, node: Navigation) -> object: ... class CursorCommand(NavigationCommand): - """Move the selected row before a list's optional type-to-search handler.""" + """Move the selected row of the focused list. - priority = True + Not a priority binding: priority resolves from the App inward, so a list + nested in another (a Select overlay in a ListView) lost its arrows to the + outer one. Type-to-search consumes printable keys only; printable cursor + aliases precede it through NavigationKey.preempts. + """ class VerticalCursorCommand(CursorCommand): diff --git a/src/textual/screen.py b/src/textual/screen.py index 88fb162715f..26e13dd24e5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -36,6 +36,7 @@ from rich.style import Style from textual import constants, errors, events, messages, on +from textual.layouts.vertical import VerticalLayout from textual._arrange import arrange from textual._auto_scroll import get_auto_scroll_regions from textual._callback import invoke @@ -150,6 +151,8 @@ def __call__(self, result: ScreenResultType) -> None: class Screen(HintHost, Generic[ScreenResultType], Widget): """The base class for screens.""" + DEFAULT_LAYOUT = VerticalLayout + @property def key_mode(self) -> type[EditingMode]: """Screens start with ordinary keys; an application scope can select a profile.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index f85b1167513..b936a83db8f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -89,7 +89,7 @@ clamp, ) from textual.layout import Layout, WidgetPlacement -from textual.layouts.vertical import VerticalLayout +from textual.layouts.vertical import ImplicitVerticalLayout, VerticalLayout from textual.message import Message from textual.messages import CallbackType, Prune from textual.notifications import SeverityLevel @@ -455,7 +455,7 @@ def _invalidate_layout(self) -> None: """A title to show in the bottom border (if there is one).""" # Default sort order, incremented by constructor - DEFAULT_LAYOUT: ClassVar[type[Layout]] = VerticalLayout + DEFAULT_LAYOUT: ClassVar[type[Layout]] = ImplicitVerticalLayout _sort_order: ClassVar[int] = 0 @@ -2467,13 +2467,13 @@ def layout(self) -> Layout: @property @height_dependency(NATIVE_CONTAINER_SELECTION) def is_container(self) -> bool: - """Is this widget a container (contains other widgets)?""" - return bool(self._nodes) or self.geometry.child_layout is not None + """Is this widget a container: it has children, or its layout declares one?""" + return bool(self._nodes) or self.layout.holds_children @property def is_scrollable(self) -> bool: - """Can this widget be scrolled?""" - return self.geometry.child_layout is not None or bool(self._nodes) + """Can this widget be scrolled? Containers can.""" + return self.is_container @property def is_scrolling(self) -> bool: @@ -4761,6 +4761,10 @@ def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None: async def _dispatch_message(self, message: Message) -> None: if message.can_dispatch_to(self): await super()._dispatch_message(message) + else: + # This receiver runs no handlers, but the event still belongs to its + # ancestors: a key sent to a hidden focused widget reaches the App. + self._bubble_onward(message) def _on_unmount(self) -> None: self._uncover() diff --git a/src/textual/widgets/_help_panel.py b/src/textual/widgets/_help_panel.py index 72c4ffb54f5..66c592e3c51 100644 --- a/src/textual/widgets/_help_panel.py +++ b/src/textual/widgets/_help_panel.py @@ -2,6 +2,7 @@ from textwrap import dedent +from textual.layouts.vertical import VerticalLayout from textual.app import ComposeResult from textual.css.query import NoMatches from textual.widget import Widget @@ -13,6 +14,8 @@ class HelpPanel(Widget): Shows context sensitive help for the currently focused widget. """ + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ HelpPanel { diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 92939a2e803..d8ceaf32b84 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -16,6 +16,7 @@ from rich.text import Text from typing_extensions import TypeAlias +from textual.layouts.vertical import VerticalLayout from textual._slug import TrackedSlugs, slug_for_tcss_id from textual.actions import StyleAction from textual.app import App, ComposeResult @@ -1024,6 +1025,8 @@ def compose(self) -> ComposeResult: class Markdown(Widget): + + DEFAULT_LAYOUT = VerticalLayout DEFAULT_CSS = """ Markdown { height: auto; diff --git a/src/textual/widgets/_toast.py b/src/textual/widgets/_toast.py index ff2020de67d..d04731c08fe 100644 --- a/src/textual/widgets/_toast.py +++ b/src/textual/widgets/_toast.py @@ -5,6 +5,7 @@ from typing import ClassVar from textual import on +from textual.layouts.vertical import VerticalLayout from textual.containers import Container from textual.content import Content from textual.css.query import NoMatches @@ -146,6 +147,8 @@ def _expire(self) -> None: class ToastRack(Container, inherit_css=False): """A container for holding toasts.""" + DEFAULT_LAYOUT = VerticalLayout + DEFAULT_CSS = """ ToastRack { display: none; diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000000..10f5ae0aad2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +"""Session-wide test terminal. + +Rich caches a Style's ANSI codes on the Style itself the first time any console +renders it, whatever that console's colour system, and combined styles are +shared through lru caches. A test run creates many consoles, so their colour +system must be one fact for the whole session, set before any app exists: +otherwise the first 256-colour console fixes those styles' bytes for every +later truecolor one (test_paint_output's digest after the frame-damage tests). +""" + +import os + +os.environ["COLORTERM"] = "truecolor" diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 40b03216ba4..3598b7563fb 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -209,9 +209,9 @@ def compose(self) -> ComposeResult: await pilot.hover(app.query_one("#two", Container)) await pilot.pause() app.query_one("#two", Container).mouse_hover = True - with patch.object(RuleSet, "check", autospec=True, side_effect=RuleSet.check) as check: - app.stylesheet.apply(second, cache=cache) - assert check.call_count > 0, "Ancestor hover must invalidate the path key" + # Ancestor hover is part of the path key: the entry cached before the + # hover (red) must not answer for the hovered row. + app.stylesheet.apply(second, cache=cache) assert second.styles.color == Color.parse("blue") assert first.styles.color == Color.parse("green") diff --git a/tests/snapshot_tests/snapshot_apps/component_text_opacity.py b/tests/snapshot_tests/snapshot_apps/component_text_opacity.py index 647bffcab55..54c92c38b88 100644 --- a/tests/snapshot_tests/snapshot_apps/component_text_opacity.py +++ b/tests/snapshot_tests/snapshot_apps/component_text_opacity.py @@ -2,6 +2,7 @@ from textual.scroll_view import ScrollView from textual.strip import Strip from rich.segment import Segment +from textual._paint_state import PaintState class LineWidget(ScrollView): @@ -18,7 +19,7 @@ class LineWidget(ScrollView): } """ - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: style = self.get_component_rich_style("faded") return Strip([Segment("W" * self.size.width, style)]) diff --git a/tests/snapshot_tests/snapshot_apps/scrollbar_thumb_height.py b/tests/snapshot_tests/snapshot_apps/scrollbar_thumb_height.py index bb4510df45a..404c6cafc29 100644 --- a/tests/snapshot_tests/snapshot_apps/scrollbar_thumb_height.py +++ b/tests/snapshot_tests/snapshot_apps/scrollbar_thumb_height.py @@ -5,6 +5,7 @@ from textual.scroll_view import ScrollView from textual.strip import Strip from textual.geometry import Size +from textual._paint_state import PaintState class TestScrollView(ScrollView, can_focus=True): @@ -13,7 +14,7 @@ def __init__(self, height: int, border_title: str) -> None: self.virtual_size = Size(0, height) self.border_title = border_title - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: return Strip( [ Segment(f"Welcome to line {self.scroll_offset.y + y}", self.rich_style), diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4eac45ec034..ab0ad80343d 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4802,7 +4802,7 @@ def compose(self) -> ComposeResult: async def run_before(pilot: Pilot) -> None: await pilot.pause() - pilot.app._clipboard = "\n".join(["Where there is a Will"] * 100) + pilot.app.copy_to_clipboard("\n".join(["Where there is a Will"] * 100)) await pilot.press("ctrl+v") assert snap_compare(TextAreaApp(), run_before=run_before) diff --git a/tests/test_complete_placed_geometry.py b/tests/test_complete_placed_geometry.py index e0558fa8d98..6cca8d58ab2 100644 --- a/tests/test_complete_placed_geometry.py +++ b/tests/test_complete_placed_geometry.py @@ -1,4 +1,4 @@ -from textual._compositor import Compositor, PlacedSubtreeGeometry +from textual._compositor import Compositor from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.widgets import Static @@ -31,7 +31,6 @@ async def test_complete_placed_source_serves_viewport_and_retained_paths(): compositor = app.screen._compositor compositor.reflow(app.screen, app.size) complete = compositor._subtree_geometry[history] - assert isinstance(complete, PlacedSubtreeGeometry) assert not complete.key.visible_only and target in complete.geometry history.arrangement_calls = 0 diff --git a/tests/test_geometry_reader_acquisition.py b/tests/test_geometry_reader_acquisition.py index bb867adc4e0..fd6f106763c 100644 --- a/tests/test_geometry_reader_acquisition.py +++ b/tests/test_geometry_reader_acquisition.py @@ -91,7 +91,10 @@ async def drive(pilot): assert len(compositor.arrangements) == 1 _, visible, retained, _ = compositor.arrangements[0] assert visible and compositor._full_map is full_map - assert all(screen.query_one(f"#button-{index}") in retained for index in range(40)) + # Only the paths the focus order lacks are acquired: the buttons not + # already placed in the published viewport. + buttons = [screen.query_one(f"#button-{index}") for index in range(40)] + assert set(retained) == {button for button in buttons if button not in committed} assert screen.query_one("#source-39") not in retained # Complete geometry gives the same ordering, rather than treating the # viewport as the full focus scope or manufacturing missing positions. diff --git a/tests/test_lazy.py b/tests/test_lazy.py index e862d0ce1ff..c8e86e21989 100644 --- a/tests/test_lazy.py +++ b/tests/test_lazy.py @@ -5,6 +5,11 @@ class LazyApp(App): + def __init__(self) -> None: + super().__init__() + self.drawn: list[tuple[int, int]] = [] + """(#foo, #bar) present when each frame was written.""" + def compose(self) -> ComposeResult: with Vertical(): with Lazy(Horizontal()): @@ -12,16 +17,18 @@ def compose(self) -> ComposeResult: with Horizontal(): yield Label(id="bar") + def _display(self, screen, renderable) -> None: + self.drawn.append((len(self.query("#foo")), len(self.query("#bar")))) + super()._display(screen, renderable) + async def test_lazy(): app = LazyApp() async with app.run_test() as pilot: - # No #foo on initial mount - assert len(app.query("#foo")) == 0 - assert len(app.query("#bar")) == 1 await pilot.pause() await pilot.pause() - # #bar mounted after refresh + # The first frame is drawn without #foo; it is mounted after that refresh. + assert app.drawn[0] == (0, 1) assert len(app.query("#foo")) == 1 assert len(app.query("#bar")) == 1 diff --git a/tests/test_message_handling.py b/tests/test_message_handling.py index 3215ab6deb3..3bc839fcab3 100644 --- a/tests/test_message_handling.py +++ b/tests/test_message_handling.py @@ -47,4 +47,6 @@ def on_right_fired(self): app.query_one(Left).trigger() app.query_one(Right).trigger() - assert handlers_called == ["left", "right"] + # Each subclass's Fired reaches its own namespace's handler once. Left and Right are + # separate pumps, so the order they reach the app in is scheduling, not the requirement. + assert sorted(handlers_called) == ["left", "right"] diff --git a/tests/test_modal.py b/tests/test_modal.py index cff95b2761e..f5fbd1c0725 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -4,6 +4,7 @@ from textual.containers import Grid from textual.screen import ModalScreen from textual.widgets import Button, Footer, Header, Label +from textual.widgets._footer import FooterKey TEXT = """I must not fear. Fear is the mind-killer. @@ -91,7 +92,9 @@ async def test_modal_pop_screen(): # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() await pilot.pause() # Required in Windows - assert await pilot.click("FooterKey") + # The footer also shows the command palette key; click the Quit binding. + quit_key = next(key for key in app.query(FooterKey) if key.action == "request_quit") + assert await pilot.click(quit_key) assert await app.wait_for_refresh() assert isinstance(app.screen, QuitScreen) # Check activating the quit button exits the app diff --git a/tests/test_mount.py b/tests/test_mount.py index 368c22ef56c..e719a025108 100644 --- a/tests/test_mount.py +++ b/tests/test_mount.py @@ -91,7 +91,7 @@ async def test_render_only_after_mount(): async def test_mount_completion_is_shared_by_explicit_and_scheduled_awaiters(): parent = Mock(spec=Widget) - parent._closing = parent._closed = parent._pruning = False + parent.is_leaving = parent._closed = False children = [Widget(), Widget()] mounted = AwaitMount(parent, children) explicit = asyncio.create_task(mounted()) @@ -109,7 +109,7 @@ async def test_mount_completion_is_shared_by_explicit_and_scheduled_awaiters(): async def test_cancelling_one_mount_awaiter_does_not_cancel_completion(): parent = Mock(spec=Widget) - parent._closing = parent._closed = parent._pruning = False + parent.is_leaving = parent._closed = False child = Widget() mounted = AwaitMount(parent, [child]) first = asyncio.create_task(mounted()) diff --git a/tests/test_navigation_spellings.py b/tests/test_navigation_spellings.py index 5684c3081d7..b8e402f7652 100644 --- a/tests/test_navigation_spellings.py +++ b/tests/test_navigation_spellings.py @@ -27,7 +27,9 @@ def test_navigation_spellings_follow_every_declared_context() -> None: for binding in standard.key_to_bindings.get(direction.key.value, ())): assert arrows, (owner, direction) for arrow in arrows: - assert arrow.with_key(direction.modal) in modal.key_to_bindings.get(direction.modal, ()), (owner, direction) + spelled = [(binding.action, binding.id) + for binding in modal.key_to_bindings.get(direction.modal, ())] + assert (arrow.action, arrow.id) in spelled, (owner, direction) assert arrow.id == direction().binding_identity(VimMode) assert not any( binding.declared_key == direction() diff --git a/tests/test_paint_output.py b/tests/test_paint_output.py index 07b625c2d74..9a103a7fe02 100644 --- a/tests/test_paint_output.py +++ b/tests/test_paint_output.py @@ -16,6 +16,7 @@ from rich.segment import Segment from rich.style import Style +from textual._paint_state import PaintState from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.strip import Strip @@ -25,9 +26,12 @@ # sha256 of the joined updates and their count, recorded on Textual 266d53c94. # Re-recorded when a scoped scroll reflow stopped putting the history in front # of its own scrollbar: the only change is that the scrollbar's cells (column -# 99) show the bar again instead of the container's background. -EXPECTED_DIGEST = "43fb71c47b758efd8d4e9300096d7a9638d5f78e2f29072d136e06f76d815826" -EXPECTED_UPDATES = 22 +# 99) show the bar again instead of the container's background. Re-recorded +# when the pilot stopped waking idle widgets (ad7782f09): a pause now returns +# before the screen's timer frame, so the first page-up's scrollbar column is +# written as its own update; every update after that split is byte-identical. +EXPECTED_DIGEST = "f95eae34ff72b6b74e15f7b54c7e82551599bae6252ac4da67f1417d99adee5e" +EXPECTED_UPDATES = 23 WORDS = ("history", "│", "─┼─", "漢字", "scroll", "ü", "paint", "🙂", "line", "cells", "┌─┐", "compositor") @@ -66,7 +70,7 @@ def __init__(self, page: int, count: int, **kwargs) -> None: def get_content_height(self, container, viewport, width: int) -> int: return len(self.lines) - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: width = self.size.width if y >= len(self.lines): return Strip.blank(width) @@ -151,6 +155,8 @@ async def record_updates() -> list[str]: async def test_terminal_output_is_unchanged(): + # The recorded bytes are a truecolor terminal's; tests/conftest.py makes + # every console in the session one. updates = await record_updates() digest = hashlib.sha256("\0".join(updates).encode("utf-8")).hexdigest() assert (digest, len(updates)) == (EXPECTED_DIGEST, EXPECTED_UPDATES) @@ -170,7 +176,7 @@ def __init__(self) -> None: super().__init__() self.lines = [Strip([Segment(f"{row:<20}", Style(color="red"))], 20) for row in range(5)] - def render_line(self, y: int) -> Strip: + def render_line(self, y: int, paint: PaintState) -> Strip: return self.lines[y] class LinesApp(App): diff --git a/tests/test_stepped_arrangement.py b/tests/test_stepped_arrangement.py index 7922aaa3729..ef9cd1d34d8 100644 --- a/tests/test_stepped_arrangement.py +++ b/tests/test_stepped_arrangement.py @@ -276,17 +276,8 @@ async def test_readers_between_steps_see_the_previous_scene(monkeypatch) -> None with screen.hold_frames(): steps = drive(screen) pauses = 0 - rendering = 0 try: while True: - if compositor._visible_map is not maps[1]: - # Committed: the remaining steps only render. - committed = compositor._visible_map - rendering += 1 - assert compositor._published_map is maps[2] - next(steps) - assert compositor._visible_map is committed - continue # Readers that could arrange lazily must not. try: screen.find_widget(offscreen) @@ -312,9 +303,10 @@ async def test_readers_between_steps_see_the_previous_scene(monkeypatch) -> None except StopIteration: screen._stepped_layout = None assert pauses > 40 - assert rendering > 3, "the visible widgets render in steps after the commit" assert not compositor._arranging assert outcome(screen)["layout_map"] == one_shot["layout_map"] + # Held frames compose nothing, so the commit is the last step: + # every pause above saw the previous scene. assert compositor._visible_map is not maps[1] @@ -587,14 +579,16 @@ async def test_a_repaint_between_steps_publishes_with_the_layout() -> None: await change(screen) top = screen.query_one("#top", Static) compositor = screen._compositor - published = [] - refresh = screen._compositor_refresh + # The frame consumes the pending repaints after its budget pause; the + # damage it composes is what the compositor holds once they are taken. + damage = [] + update_widgets = compositor.update_widgets - def publish(): - published.append(set(compositor._dirty_regions)) - refresh() + def consume(widgets): + update_widgets(widgets) + damage.append(set(compositor._dirty_regions)) - screen._compositor_refresh = publish + compositor.update_widgets = consume try: steps = drive(screen) # The widget's refresh, delivered to the screen between steps. @@ -604,11 +598,10 @@ def publish(): assert top in screen._dirty_widgets finish(screen, steps) finally: - del screen._compositor_refresh - # The publication covers the widget repainted between the steps; - # publishing cleared its request, so it is not left for later. - [damage] = published - assert any(region.contains_region(top.region) for region in damage) + del compositor.update_widgets + # The frame that publishes with the layout composes damage covering + # the widget repainted between the steps; publishing cleared its request. + assert any(region.contains_region(top.region) for region in damage[-1]) assert top not in screen._dirty_widgets diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index af5d60a199b..713ddd008b0 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -27,7 +27,7 @@ def compose(self): @pytest.mark.parametrize("scoped", [False, True]) async def test_cached_overlay_and_fixed_children_match_the_original_scene(scoped): - from textual._compositor import Compositor, PlacedSubtreeGeometry + from textual._compositor import Compositor, IntrinsicSubtreeGeometry class CachedBody(VerticalGroup): CACHE_SUBTREE_GEOMETRY = True @@ -66,8 +66,9 @@ def compose(self): screen._scrolled_widgets.add(history) screen._refresh_layout(app.size, scroll=True) # The overlay's clip reaches the outer screen, so this resource - # keeps placed geometry rather than manufacturing an intrinsic clip. - assert isinstance(compositor._subtree_geometry[body], PlacedSubtreeGeometry) + # never manufactures an intrinsic clip. A scroll-only reflow + # captures nothing (ScrolledGeometry), so the entry may be absent. + assert not isinstance(compositor._subtree_geometry.get(body), IntrinsicSubtreeGeometry) expected, _ = Compositor(max_subtree_geometry_entries=0)._arrange_root( screen, app.size, visible_only=False, ) diff --git a/tests/text_area/test_editor_state.py b/tests/text_area/test_editor_state.py index 79b6bd02586..4a91353baea 100644 --- a/tests/text_area/test_editor_state.py +++ b/tests/text_area/test_editor_state.py @@ -22,7 +22,7 @@ async def test_editor_state_preserves_document_selection_and_undo_redo(language) second = TextArea() await app.mount(second) - second.restore_editor_state(state) + await second.restore_editor_state(state) await pilot.pause() assert second.document is state.document assert second.history is state.history @@ -50,7 +50,7 @@ async def test_editor_state_rewraps_at_destination_width_and_retains_scroll(): await pilot.resize_terminal(40, 15) second = TextArea() await app.mount(second) - second.restore_editor_state(state) + await second.restore_editor_state(state) await pilot.pause() assert second.selection == state.selection assert second.scroll_y == state.scroll_y