From b28799f8c6a54b98464ef97f6f88ad869926e466 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:17:42 -0500 Subject: [PATCH 01/27] CI tests the Pythons it can provision: 3.9 never set up, and fail-fast cancelled every build GitHub's ubuntu-latest (26.04) has no Python 3.9, so setup failed on every PR and fail-fast cancelled every other build: no Textual PR has run its tests in CI (#251, #252 and #253 all show the cancelled matrix). The matrix starts at 3.10, and the four 3.9-only branches go with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- .github/workflows/pythonpackage.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 407ad33b4f8..8d686bd492a 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.10", "3.11", "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 From 43063f910c5711c318d52139eb557ce78faeb37e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:36:14 -0500 Subject: [PATCH 02/27] CI starts at Python 3.12: the source already uses 3.12 syntax box_model.py and css/native.py declare type aliases with 'type X = ...' (PEP 695), so 3.10 and 3.11 cannot import textual: #255's 3.10 build failed collection with SyntaxError at box_model.py:30 and fail-fast cancelled the rest. The matrix tests the Pythons the code runs on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 8d686bd492a..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.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] defaults: run: shell: bash From 40187c1fa373e3fdb897507c7c5e9103eefa7d54 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:36:51 -0500 Subject: [PATCH 03/27] test_message_inheritance_namespace asserts namespaces, not two pumps' delivery order Issue #1814's requirement is that each subclass's Fired reaches its own namespace's handler. The test also pinned the order two independent pumps reach the app in, which the message loop does not promise; it failed 5/5 on main (right before left). It now checks each handler ran once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_message_handling.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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"] From 48b4dd65d27b079dcd150b08e35606dbd8ca187b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:46:08 -0500 Subject: [PATCH 04/27] Paint tests take the PaintState render_line contract; the publish spy forwards the deadline generator Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_paint_output.py | 5 +++-- tests/test_stepped_arrangement.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_paint_output.py b/tests/test_paint_output.py index 07b625c2d74..6b9ef504e3d 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 @@ -66,7 +67,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) @@ -170,7 +171,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..1c94c468c23 100644 --- a/tests/test_stepped_arrangement.py +++ b/tests/test_stepped_arrangement.py @@ -590,9 +590,9 @@ async def test_a_repaint_between_steps_publishes_with_the_layout() -> None: published = [] refresh = screen._compositor_refresh - def publish(): + def publish(deadline=None): published.append(set(compositor._dirty_regions)) - refresh() + yield from refresh(deadline) screen._compositor_refresh = publish try: From 070541849c2ac01fa18229171c94b03a0823baf3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:51:32 -0500 Subject: [PATCH 05/27] Stepped and paint tests read the compositor's one-step frame contract 76119a088 deleted the per-widget render steps after a layout's commit: composition is one atomic step after the budget pause, and it consumes pending repaints there. Under held frames the commit is now the layout's last step, so the readers test asserts every pause saw the previous scene instead of counting render steps that no longer exist. The repaint spy reads the damage where the frame takes the repaints (update_widgets), not before the pause. The paint digest is re-recorded: ad7782f09's pilot returns before the screen's timer frame, so the first page-up's scrollbar column is written as its own update (22 -> 23). Every update after that split is byte-identical, under rich 14.2.0 (the lock) and 15.0.0 alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_paint_output.py | 9 +++++--- tests/test_stepped_arrangement.py | 35 +++++++++++++------------------ 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/tests/test_paint_output.py b/tests/test_paint_output.py index 6b9ef504e3d..b5f47534b72 100644 --- a/tests/test_paint_output.py +++ b/tests/test_paint_output.py @@ -26,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") diff --git a/tests/test_stepped_arrangement.py b/tests/test_stepped_arrangement.py index 1c94c468c23..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(deadline=None): - published.append(set(compositor._dirty_regions)) - yield from refresh(deadline) + 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(deadline=None): 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 From f272b9d4257c708bf5586d0d35f58150468cd481 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:51:32 -0500 Subject: [PATCH 06/27] Mount tests set is_leaving, the property AwaitMount reads AwaitMount._finish (widget.py) skips the parent refresh when parent.is_leaving or _closed. The tests' Mock(spec=Widget) hand-set the private _closing/_pruning flags behind is_leaving, so is_leaving stayed a truthy Mock and refresh was never called. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_mount.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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()) From 7b268a20ff4b65992a00d0ffd35f11a8508d8275 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:53:41 -0500 Subject: [PATCH 07/27] Editor-state tests await restore_editor_state 0212c053c made TextArea.restore_editor_state a coroutine (it closes the temporary editing mode). The tests still called it bare, so the coroutine never ran and nothing was restored: a fresh Document and an empty selection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/text_area/test_editor_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 9ec4967dd953a5b115bd99f0d9cdb6e39fa3bf92 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:53:41 -0500 Subject: [PATCH 08/27] Viewport overlay test asserts no intrinsic clip, not a cache entry b31f2ede8 made viewport layout choose its geometry type from visible_only, and a scroll-only reflow arranges through ScrolledGeometry, which captures no subtree. The test indexed the cache entry and raised KeyError; its stated invariant is that the overlay's resource is never an intrinsic clip, so it asserts exactly that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_viewport_geometry_targets.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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, ) From b9e9b2b88161b53d7934b3a67968ab3894b62e65 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:56:13 -0500 Subject: [PATCH 09/27] Delete the debt-ratchet and black workflows: neither can pass debt-ratchet installs agent-comms at 88f4e6ee, which crashes at import on Python 3.14.10, and agent-comms main no longer ships agent-comms-ratchet at all (the NRA gate replaced it). black_format pins Python 3.11 and black 24.1.1, which cannot parse the tree's PEP 695 syntax (box_model.py:30) and would reformat about thirty files nobody formats with it. Both were red on every PR, so a red check here told nobody anything. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- .github/workflows/black_format.yml | 22 ------------------- .github/workflows/debt-ratchet.yml | 34 ------------------------------ 2 files changed, 56 deletions(-) delete mode 100644 .github/workflows/black_format.yml delete mode 100644 .github/workflows/debt-ratchet.yml 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" From c00618692e325f65cdc2261b48eb25276acfe2ec Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 10:56:53 -0500 Subject: [PATCH 10/27] test_lazy checks the first drawn frame, not when run_test returns 603a769ed runs after-refresh callbacks once the frame after them publishes, which is Lazy's contract: the first frame is drawn without the lazy widget, then it mounts. run_test now returns after that second frame, so asserting #foo absent at entry tested pilot timing. The app records what each written frame contained and the test asserts the first had no #foo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_lazy.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 From 4c25c83911c23fe82bb4ef142718159aa275c536 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:00:11 -0500 Subject: [PATCH 11/27] A pushed screen is restyled with the CSS loaded for it push_screen and switch_screen mount the screen (_get_screen) and only then read its CSS (_load_screen_css). bc570b4d8 made a CSS source change queue restyles on the screens in the stacks, and ScreenResume restyle only those queued nodes; the incoming screen is not on a stack yet, so its own CSS was never applied. A SeeThrough screen with background: transparent stayed opaque, and the screens behind it were not drawn. The loader holds the screen the rules are for and restyles it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 47f2df6be26..0253428bf1e 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. From 7d0e6c777ac71602df3356a03ee0b850a0210dba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:00:11 -0500 Subject: [PATCH 12/27] Modal pop test clicks the Quit footer key, not the first FooterKey ad356fbe5 reconciles footer groups and puts the common keys (the command palette) first, so clicking the first FooterKey opened the palette. Issue 4656 is about clicking the Quit binding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_modal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From c3dd2b601a8e7b8aabda776d1fa36db4dc705e45 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:00:37 -0500 Subject: [PATCH 13/27] Hover path-key test asserts the hovered colour, not a rule re-check count 065244c49 made ancestor hover part of the path key and restyles the hovered row when hover changes, so applying it afterwards hits that entry with no rule checks. The colour is the contract: an entry keyed without hover would answer red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/css/test_stylesheet.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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") From 732a223be0811916c02575fd8390b35772c45281 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:01:16 -0500 Subject: [PATCH 14/27] Focus-order acquisition test asserts exactly the unplaced buttons are retained 2094493ce acquires only required geometry paths: buttons already placed in the published viewport are not re-acquired. The test demanded all forty; it now demands exactly the ones the viewport lacks, which also catches over-acquisition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_geometry_reader_acquisition.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From a21208cc4cbbc149803ce77a932991f1ed9c0efa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:02:40 -0500 Subject: [PATCH 15/27] A complete intrinsic capture serves a viewport request, like a placed one b31f2ede8 made a full arrangement capture IntrinsicSubtreeGeometry, but only PlacedSubtreeGeometry.matches let a complete (non-visible_only) capture answer a visible_only request. The next reflow_visible at the same scroll re-arranged the scroll container (arrangement_calls 1, expected 0). The rule lives once on SubtreeGeometry.matches; each member declares only which key inputs its placements depend on (_comparable: the key itself, or its placement-free intrinsic form). The test drops its pin on the capture's class and keeps the reuse and reference-equality assertions. Geometry and compositor tests: 251 passed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/_compositor.py | 32 +++++++++++++++----------- tests/test_complete_placed_geometry.py | 3 +-- 2 files changed, 20 insertions(+), 15 deletions(-) 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/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 From ffb578dc99bc6e328d8bdd04dcb2dea461b07646 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:06:37 -0500 Subject: [PATCH 16/27] A container is declared by its layout, so an empty one is still a container ef8f854bb moved layout: vertical out of the containers' CSS into DEFAULT_LAYOUT = VerticalLayout; d19dbd541 then deleted those nine declarations as redundant, because they equalled Widget's own default. Equal values, different facts: Widget's default is what a leaf has, a container's is a declaration. is_container and is_scrollable (one expression written twice) still read only the CSS child_layout, so an empty VerticalScroll stopped being scrollable: overflow-x: scroll drew no scrollbar (upstream issue 1616's test, red since ef8f854bb). - Layout.holds_children: a declared layout makes its widget a container. ImplicitVerticalLayout is the leaf default and holds none. - is_container reads the widget's layout; is_scrollable is is_container. - Container, ScrollableContainer, Vertical, VerticalGroup, Center, Right, Middle, CenterMiddle, Screen, Markdown, ToastRack and HelpPanel declare VerticalLayout again, as their CSS did. Textual suite without snapshots: 3849 passed, 0 failed (snap_compare absent locally). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/containers.py | 17 +++++++++++++++++ src/textual/layout.py | 2 ++ src/textual/layouts/vertical.py | 7 +++++++ src/textual/screen.py | 3 +++ src/textual/widget.py | 12 ++++++------ src/textual/widgets/_help_panel.py | 3 +++ src/textual/widgets/_markdown.py | 3 +++ src/textual/widgets/_toast.py | 3 +++ 8 files changed, 44 insertions(+), 6 deletions(-) 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/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/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..9c5f6cf60d8 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: 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; From 9420da66734fa710cc966e189ca8a06e253c357e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:15:56 -0500 Subject: [PATCH 17/27] The paint digest test pins its terminal to truecolor Its digest was recorded under COLORTERM=truecolor; CI has no COLORTERM, so the console downgraded colours and the same 23 updates hashed differently. The test owns the terminal it records. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_paint_output.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_paint_output.py b/tests/test_paint_output.py index b5f47534b72..22c08b17a30 100644 --- a/tests/test_paint_output.py +++ b/tests/test_paint_output.py @@ -154,7 +154,10 @@ async def record_updates() -> list[str]: return app.updates -async def test_terminal_output_is_unchanged(): +async def test_terminal_output_is_unchanged(monkeypatch): + # The recorded bytes are a truecolor terminal's: without COLORTERM the + # console downgrades colours and every byte after the first style changes. + monkeypatch.setenv("COLORTERM", "truecolor") updates = await record_updates() digest = hashlib.sha256("\0".join(updates).encode("utf-8")).hexdigest() assert (digest, len(updates)) == (EXPECTED_DIGEST, EXPECTED_UPDATES) From e25defbba1d0c503302de8a24bf17f75d2b94028 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:26:19 -0500 Subject: [PATCH 18/27] Navigation spellings test checks each arrow's modal spelling by action and identity Binding.for_mode spells an arrow under its modal key keeping every other field, including an author's key_display; with_key resets key_display, so comparing whole Bindings failed for the snapshot apps that set one (only collected in CI). The test protects that the spelling exists with the shared identity, not a display policy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_navigation_spellings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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() From a50a10d64c7599726736b649aaae9c794f8181a3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:38:32 -0500 Subject: [PATCH 19/27] Keylines draw for containers that declare their layout in the class _render_container drew a keyline only when the CSS declared a layout (geometry.child_layout). Since ef8f854bb, Grid, Horizontal and the other containers declare their layout as DEFAULT_LAYOUT, so a keyline rule on them drew nothing: the keyline snapshots lost every line. It now asks is_container, the one container predicate. All three keyline snapshots match upstream pixel for pixel (theme colours normalised). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd (cherry picked from commit fb8a1c71cce366f2678b8479ec308c11b8f62686) --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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]) From de83ad67b74852c49fc34aff434c851055056674 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:57:14 -0500 Subject: [PATCH 20/27] A key sent to a hidden focused widget still reaches its ancestors InputEvent.can_dispatch_to (38e600332) refuses input handlers on a widget under a display:none ancestor, but Widget._dispatch_message then dropped the event outright, so it never bubbled. Auto-focus may focus a hidden widget (upstream does too), and every key after that vanished before Screen and App: richlog_width's App.key_p never ran and its RichLog stayed empty. The bubble step moves out of MessagePump._on_message into _bubble_onward, and a refused receiver passes the event on through it without running its own handlers. A detached widget still bubbles nothing (is_attached is False). Proof: a hidden focused RichLog plus App.key_p; before, the key stopped at RichLog; now RichLog -> Screen -> App.key_p, matching upstream's base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/message_pump.py | 4 +++- src/textual/widget.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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/widget.py b/src/textual/widget.py index 9c5f6cf60d8..b936a83db8f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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() From a3e3fc1298afe6335580ac71fa775c253a4ce213 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:57:14 -0500 Subject: [PATCH 21/27] A rule set to initial reads as its declared default, never None The builder stores initial as None so it still overrides less specific rules, and StyleProperty.__get__ handed that None back. The default theme resolves $link-background to initial, so Widget's rule read link_background as None and Stylesheet.css raised AttributeError in Styles.css_lines. Any widget logging the stylesheet died at mount: scoped_css and unscoped_css lost their widgets. The crash is upstream's too; the fork's atomic mount made it drop the widgets. Proof: App().stylesheet.css raises before, succeeds after; test_scoped_css renders 0 px from upstream's reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/css/_style_properties.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 From a87ee2fa5b1874ec2217dc7b7b5c77a336e59015 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:57:14 -0500 Subject: [PATCH 22/27] Line API examples and snapshot apps take render_line(y, paint) Widget.render_line is (y, paint); two snapshot apps and the four checker guide examples still defined the deleted one-argument form, so they raised and rendered blank. The guide now says what paint carries. Proof: test_scrollbar_thumb_height and test_component_text_opacity render 0 px from upstream's reference (were fully blank). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- docs/examples/guide/widgets/checker01.py | 3 ++- docs/examples/guide/widgets/checker02.py | 3 ++- docs/examples/guide/widgets/checker03.py | 3 ++- docs/examples/guide/widgets/checker04.py | 3 ++- docs/guide/widgets.md | 2 +- tests/snapshot_tests/snapshot_apps/component_text_opacity.py | 3 ++- tests/snapshot_tests/snapshot_apps/scrollbar_thumb_height.py | 3 ++- 7 files changed, 13 insertions(+), 7 deletions(-) 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/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), From 1496e824711f269dfe12a7cb83a946a83ab75411 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 11:58:42 -0500 Subject: [PATCH 23/27] The paste snapshot fills the clipboard through its owner test_text_area_paste wrote App._clipboard, a private string 4cbaf90b2 replaced with typed clipboard_content; nothing reads the old name, so ctrl+v pasted an empty clipboard and the snapshot was blank. copy_to_clipboard is the owner. Proof: test_text_area_paste renders 312 px from upstream's reference, the terminal cursor only (was 85,696, blank). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/snapshot_tests/test_snapshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 882b6f736e6905a8aee9f44edc5dfc90c9493c83 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 12:23:55 -0500 Subject: [PATCH 24/27] The app's widgets mount before the screen's own tooltip and toasts App._on_compose mounted [*screen._nodes, *app widgets]; mount_all leaves already-mounted nodes in place, and with the fork's atomic screen composition the screen's Tooltip (a Static) already exists there. query_one(Static) in on_mount then found the hidden tooltip: test_static_content_property set content on it and the app's own Static kept 'Hello, World'. Upstream only worked because its tooltip did not exist yet at that point. The app's widgets now mount before the screen's existing nodes; each system widget paints on its own layer, so order does not change the picture. Proof: test_static_content_property 0 px from upstream (was 1,223); every tooltip and notification snapshot stays 0 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0253428bf1e..0774301b2d6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3653,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() From a590a8a949212f6dd743f813f3f5457ed68b2c85 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 12:23:55 -0500 Subject: [PATCH 25/27] Cursor commands are ordinary bindings, not priority ones CursorCommand.priority = True (5eaa4733a) made every list's arrows priority bindings, and priority resolves from the App inward, so a list nested in another lost its arrows to the outer one: in test_select_refocus the open Select overlay was focused while down moved the ListView behind it. Type-to- search consumes printable keys only, and printable cursor aliases already precede it through NavigationKey.preempts, so the priority bought nothing. The priority ClassVar had no other writer and goes too. Proof: the key trace matches upstream's base key for key; test_select_refocus 0 px (was 386,686); 757 binding, keymap, navigation, list and input tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/navigation.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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): From edbfd5b71c704533bb48dcedf4a5ef86dc46aaba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 12:23:55 -0500 Subject: [PATCH 26/27] A named action on an arrow keeps the id its author declared Binding.for_mode gave every binding on an arrow the arrow's shared preference identity, so Binding('i,up', id='app.increment') split into an app.increment half and an UpKey half. set_keymap replaced only the first; the footer kept showing 'up' with its stale key_display. preference_identity now answers on the binding family: a named action keeps its explicit id and joins the arrow's preference only without one; native ModeBinding commands always share it. Proof: test_keymap_bindings_key_display 0 px (was 22,068); test_navigation_spellings keeps id-less arrow actions on the shared identity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/binding.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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) From ecdc214fe9bc4b70ede16f5cc8cf37cf81f1bd00 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 12:23:55 -0500 Subject: [PATCH 27/27] Every console in a test session is one truecolor terminal Rich caches a Style's ANSI codes on the Style the first time any console renders it, and combined styles are shared through lru caches. The frame-damage tests run apps with COLORTERM unset, and test_paint_output's truecolor digest then read their 256-colour bytes for one reverse style: it failed in the full suite and passed alone. tests/conftest.py now declares the session's terminal once, before any app exists, and the digest test's own monkeypatch goes. Proof: test_partial_damage_uses_current_frame_for_spans_and_chops followed by the digest test: 4 passed (was 1 failed); settled snapshots stay 0 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/conftest.py | 13 +++++++++++++ tests/test_paint_output.py | 7 +++---- 2 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 tests/conftest.py 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/test_paint_output.py b/tests/test_paint_output.py index 22c08b17a30..9a103a7fe02 100644 --- a/tests/test_paint_output.py +++ b/tests/test_paint_output.py @@ -154,10 +154,9 @@ async def record_updates() -> list[str]: return app.updates -async def test_terminal_output_is_unchanged(monkeypatch): - # The recorded bytes are a truecolor terminal's: without COLORTERM the - # console downgrades colours and every byte after the first style changes. - monkeypatch.setenv("COLORTERM", "truecolor") +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)