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