From b6039d920095e1abf396d474f18a0c543f41862e Mon Sep 17 00:00:00 2001 From: Gheat Date: Wed, 22 Jul 2026 16:37:03 -0700 Subject: [PATCH 1/2] Add KitFooter: a Footer that guarantees zero horizontal overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md's doctrine is "Footer shows the ~7 keys that matter", but nothing in ricekit enforces it — every app hand-picks which Bindings get show=True and hopes the count and terminal width cooperate. tuistore shipped with 12 show=True bindings, and at 80 columns the stock Textual Footer (a plain ScrollableContainer with an invisible scrollbar, scrollbar-size: 0 0) overflowed by 46 columns with no visual sign anything was missing. tuistore's fix was a one-off show=False pass on five bindings, tuned by hand for that app at that binding count — it doesn't stop the same mistake from happening again there or anywhere else in the suite. KitFooter subclasses Footer (compact=True by default, everything else identical) and composes exactly as the stock widget does. On mount and on every resize, it measures the real arranged width of its children via Footer's own arrange() against the available width. If it overflows, it hides (never removes) trailing children in DOM order — the ones from bindings declared latest in BINDINGS, the same priority stock Footer already renders in — one at a time, re-measuring after each, until the rest fits. Widening the terminal brings hidden keys back; nothing is a one-way hide. The docked command-palette key is budgeted for but is never itself a trim candidate. This is a safety net, not a redesign of the ~7-keys doctrine: it never shows more than an app already marked show=True, only ever less. Adds the project's first test suite (tests/test_footer.py, run via unittest) covering: a small binding set left untouched, 20+ bindings never overflowing from 40 to 120 columns, hidden keys reappearing on resize, and the command-palette key surviving heavy trimming while its width is still counted against the budget. Also adds tests.yml (ubuntu / macos / windows x Python 3.11/3.12/3.13 via astral-sh/setup-uv), since there was previously no CI test coverage at all. Updates the widgets table, __init__ docstring example, and the README quickstart to use KitFooter in place of the stock Footer. --- .github/workflows/tests.yml | 37 ++++++ README.md | 7 +- ricekit/__init__.py | 2 +- ricekit/widgets.py | 113 +++++++++++++++++- tests/test_footer.py | 223 ++++++++++++++++++++++++++++++++++++ 5 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/test_footer.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..63bf464 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install ricekit + run: uv sync --python ${{ matrix.python-version }} + + - name: Run tests + run: uv run --python ${{ matrix.python-version }} python -m unittest discover tests -v + + - name: Import sanity check + run: | + uv run --python ${{ matrix.python-version }} python -c " + import ricekit + import ricekit.widgets, ricekit.modals, ricekit.themes, ricekit.palette, ricekit.storage, ricekit.icons, ricekit.fx, ricekit.app + print('all modules import cleanly') + " diff --git a/README.md b/README.md index 6f6b644..cd21b44 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ everything a fast, clean, rice-friendly terminal app needs, minus the app. | `ricekit.themes` | five themes — `mocha`, OLED-black `void`, monochrome `onyx`, **`clear`** (transparent: your terminal's blur shows through), **`system`** (your terminal's own ANSI palette) — all sharing one `$kit-*` CSS variable contract, with the scrollbar/selection fixes baked in | | `ricekit.palette` | swappable chrome palette (`palette.text`, `.dim`, `.blue`, …) that flips to terminal ANSI colors under the `system` theme | | `ricekit.app` | `KitApp` — registers the themes, flips `ansi_color` for transparent themes, injects CSS variables, replaces the palette's theme command with a live-preview picker | -| `ricekit.widgets` | `NavList` (vim keys, quiet cursor), `Splitter` (drag-to-resize with persistence hook, double-click reset), `KitScroll`, `pop_in` (the one sanctioned animation) | +| `ricekit.widgets` | `NavList` (vim keys, quiet cursor), `Splitter` (drag-to-resize with persistence hook, double-click reset), `KitScroll`, `KitFooter` (self-measuring `Footer` — trims trailing keys until it fits, guaranteed zero horizontal overflow), `pop_in` (the one sanctioned animation) | | `ricekit.modals` | `PickerModal` (generic chooser), `ThemeModal` (restyles the app live as you scroll), `HelpModal` (keybinding cheatsheet from plain data) | | `ricekit.icons` | curated nerd-font icons as `\uXXXX` escapes + unicode state glyphs (◌ ○ ◐ ◑ ● ⊘) + mini bar gauges | | `ricekit.fx` | text effects — the letter wave (`Wave` + `wave_markup`: **G**heat → g**H**eat → …) and braille spinner frames, driven by one cheap shared ticker | @@ -48,11 +48,10 @@ uv add git+https://github.com/Gheat1/ricekit # or pip install git+… ```python from textual.binding import Binding -from textual.widgets import Footer from ricekit import KitApp, icons, palette from ricekit.storage import AppDirs -from ricekit.widgets import NavList +from ricekit.widgets import KitFooter, NavList DIRS = AppDirs("myapp") @@ -65,7 +64,7 @@ class MyApp(KitApp): def compose(self): yield NavList(id="items") - yield Footer() + yield KitFooter() def on_mount(self): self.init_kit(theme=DIRS.load_state().get("theme")) diff --git a/ricekit/__init__.py b/ricekit/__init__.py index 018a60b..12b3d3a 100644 --- a/ricekit/__init__.py +++ b/ricekit/__init__.py @@ -6,7 +6,7 @@ clean, rice-friendly terminal app needs, minus the app. from ricekit import KitApp, palette, icons - from ricekit.widgets import NavList, Splitter, KitScroll, pop_in + from ricekit.widgets import NavList, Splitter, KitScroll, KitFooter, pop_in from ricekit.modals import PickerModal, ThemeModal, HelpModal from ricekit.storage import AppDirs diff --git a/ricekit/widgets.py b/ricekit/widgets.py index cea4b55..5fb18a1 100644 --- a/ricekit/widgets.py +++ b/ricekit/widgets.py @@ -1,12 +1,18 @@ -"""Reusable widgets: vim-navigable lists, drag-to-resize splitters, motion.""" +"""Reusable widgets: vim-navigable lists, drag-to-resize splitters, an +overflow-safe footer, motion.""" from __future__ import annotations -from typing import Callable +from typing import TYPE_CHECKING, Callable +from textual import events from textual.binding import Binding from textual.containers import VerticalScroll -from textual.widgets import OptionList, Static +from textual.widget import Widget +from textual.widgets import Footer, OptionList, Static + +if TYPE_CHECKING: + from textual.screen import Screen def pop_in(widget, duration: float = 0.15) -> None: @@ -130,3 +136,104 @@ def on_click(self, event) -> None: self.app.query_one(self._target).styles.width = None if self._on_resized is not None: self._on_resized(self._target, None) + + +class KitFooter(Footer): + """A `Footer` that guarantees zero horizontal overflow. + + DESIGN.md's doctrine is "Footer shows the ~7 keys that matter" — every + app hand-picks which of its own `Binding`s get `show=True` and hopes + the count and the terminal width cooperate. Stock `Footer` is a plain + `ScrollableContainer` with an invisible scrollbar + (`scrollbar-size: 0 0`), so when they don't cooperate the excess just + scrolls off screen with no visual indication anything is missing. + + This subclass composes exactly like stock `Footer` — same children, + same CSS, `compact=True` by default instead of `False` — then, once + layout is known (on mount and on every resize), measures the real + arranged width of its children against the available width. If it + doesn't fit, it hides (never removes) trailing children in DOM order — + the ones from bindings declared latest in `BINDINGS`, the same + priority stock `Footer` already renders in — one at a time, until the + rest fits. Widening the terminal reveals hidden keys again; nothing is + ever a one-way hide. The docked command-palette key is budgeted for + but is never itself a trim candidate. + + This does not try to show more than the app marked `show=True` — it + only ever hides. An app that already fits inside ~7 keys should see + this do nothing at any reasonable width. + """ + + def __init__( + self, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + show_command_palette: bool = True, + compact: bool = True, + ) -> None: + super().__init__( + *children, + name=name, + id=id, + classes=classes, + disabled=disabled, + show_command_palette=show_command_palette, + compact=compact, + ) + + def on_mount(self) -> None: + super().on_mount() + self.call_after_refresh(self._enforce_no_overflow) + + def on_resize(self, event: events.Resize) -> None: + self._enforce_no_overflow() + + def bindings_changed(self, screen: Screen) -> None: + # Fires on mount (once bindings are known) and whenever the active + # bindings change; the base class recomposes after a refresh — chain + # our check onto the same refresh so it sees the real children. + super().bindings_changed(screen) + self.call_after_refresh(self._enforce_no_overflow) + + def _enforce_no_overflow(self) -> None: + """Trim trailing flow children until nothing overflows. + + `self.arrange()` gives a real `DockArrangeResult` computed from the + current DOM — the same math Textual itself uses to derive + `virtual_size`/`max_scroll_x` — without waiting on the reactive + pipeline to catch up, so this can run synchronously in a tight + loop. The arrangement cache is keyed on child count, not on + `display`, so it has to be cleared by hand after every toggle. + """ + if not self.is_mounted or not self.is_attached: + return + size = self.size + if size.width <= 0: + return + + # Only children in normal flow are trim candidates — the docked + # command-palette key is a fixed fixture, budgeted for below but + # never hidden. + candidates = [child for child in self.children if child.styles.dock == "none"] + if not candidates: + return + + # Un-hide everything first: this is what lets a resize back to a + # wider terminal bring previously-hidden keys back, instead of + # leaving them hidden forever. + if any(not child.display for child in candidates): + for child in candidates: + child.display = True + self._clear_arrangement_cache() + + # Hide from the end — lowest priority, latest in BINDINGS — one at + # a time, re-measuring after each, until the rest fits. + for child in reversed(candidates): + self._clear_arrangement_cache() + if self.arrange(size).total_region.width <= size.width: + return + child.display = False + self._clear_arrangement_cache() diff --git a/tests/test_footer.py b/tests/test_footer.py new file mode 100644 index 0000000..09a938d --- /dev/null +++ b/tests/test_footer.py @@ -0,0 +1,223 @@ +"""Regression tests for KitFooter's zero-horizontal-overflow guarantee. + +tuistore shipped a Footer with 12 `show=True` bindings — nowhere near the +DESIGN.md "~7 keys" target — and at 80 columns the stock Textual `Footer` +(a plain `ScrollableContainer` with `scrollbar-size: 0 0`, i.e. an +invisible scrollbar) silently overflowed by 46 columns with no visual sign +anything was missing. tuistore's fix was a hand-tuned `show=False` pass on +five bindings — a static, per-app workaround with nothing stopping the same +mistake next time a binding gets added, in tuistore or any other app on +ricekit. These tests hold KitFooter to the invariant that fix didn't +provide: `max_scroll_x` is 0 no matter how many bindings are marked +`show=True` or how narrow the terminal gets. +""" + +from __future__ import annotations + +import unittest + +from textual.app import App, ComposeResult +from textual.binding import Binding + +from ricekit.widgets import KitFooter + +WIDTHS = (40, 60, 80, 120) + + +def _visible_flow_keys(footer: KitFooter) -> list: + """Non-docked children currently displayed (i.e. not trimmed).""" + return [c for c in footer.children if c.display and c.styles.dock == "none"] + + +def _hidden_flow_keys(footer: KitFooter) -> list: + """Non-docked children currently trimmed (hidden, not removed).""" + return [c for c in footer.children if not c.display and c.styles.dock == "none"] + + +def _docked_keys(footer: KitFooter) -> list: + return [c for c in footer.children if c.styles.dock != "none"] + + +def make_app(n: int, show_command_palette: bool = True) -> type[App]: + """Build a throwaway App class with `n` distinct, always-shown bindings.""" + + bindings = [ + Binding(str(i) if i < 10 else chr(ord("a") + i - 10), f"act{i}", f"action {i}", show=True) + for i in range(n) + ] + + class FooterHarness(App): + BINDINGS = bindings + + def compose(self) -> ComposeResult: + yield KitFooter(show_command_palette=show_command_palette) + + for i in range(n): + setattr(FooterHarness, f"action_act{i}", lambda self: None) + + return FooterHarness + + +class SmallBindingSetTest(unittest.IsolatedAsyncioTestCase): + """A well-under-budget binding set (~6 keys) needs no trimming at all.""" + + async def test_fits_without_hiding_anything(self) -> None: + app_cls = make_app(6) + app = app_cls() + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + + self.assertEqual(footer.max_scroll_x, 0) + self.assertEqual(len(_hidden_flow_keys(footer)), 0) + self.assertEqual(len(_visible_flow_keys(footer)), 6) + + +class ExcessiveBindingSetTest(unittest.IsolatedAsyncioTestCase): + """Far more bindings than could ever fit — the actual tuistore scenario, + exaggerated. Zero overflow must hold at every width tested.""" + + async def test_never_overflows_across_widths(self) -> None: + app_cls = make_app(24) + for width in WIDTHS: + with self.subTest(width=width): + app = app_cls() + async with app.run_test(size=(width, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + + self.assertEqual( + footer.max_scroll_x, + 0, + f"footer overflowed at width={width}", + ) + # 24 bindings can't possibly fit even at 120 columns — + # something must have been trimmed everywhere. + self.assertGreater(len(_hidden_flow_keys(footer)), 0) + + async def test_narrower_width_hides_at_least_as_much(self) -> None: + app_cls = make_app(24) + visible_counts = {} + for width in WIDTHS: + app = app_cls() + async with app.run_test(size=(width, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + visible_counts[width] = len(_visible_flow_keys(footer)) + + ordered = sorted(visible_counts) + for narrower, wider in zip(ordered, ordered[1:]): + self.assertLessEqual( + visible_counts[narrower], + visible_counts[wider], + f"width={narrower} showed more keys than width={wider}", + ) + + +class ResizeRevealTest(unittest.IsolatedAsyncioTestCase): + """Hiding is not one-way: widening the terminal must bring back keys + that were trimmed at a narrower size, once they fit again.""" + + async def test_widening_reveals_previously_hidden_keys(self) -> None: + app_cls = make_app(24) + app = app_cls() + async with app.run_test(size=(40, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + self.assertEqual(footer.max_scroll_x, 0) + narrow_visible = len(_visible_flow_keys(footer)) + narrow_hidden = len(_hidden_flow_keys(footer)) + self.assertGreater(narrow_hidden, 0) + + await pilot.resize_terminal(120, 24) + await pilot.pause() + + self.assertEqual(footer.max_scroll_x, 0) + wide_visible = len(_visible_flow_keys(footer)) + self.assertGreater(wide_visible, narrow_visible) + + async def test_round_trip_narrow_wide_narrow(self) -> None: + """Shrink, grow, shrink back — the same keys should be hidden as + the first time at that width, not accumulate stale state.""" + app_cls = make_app(24) + app = app_cls() + async with app.run_test(size=(40, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + first_pass_visible = {c for c in _visible_flow_keys(footer)} + + await pilot.resize_terminal(120, 24) + await pilot.pause() + self.assertEqual(footer.max_scroll_x, 0) + + await pilot.resize_terminal(40, 24) + await pilot.pause() + self.assertEqual(footer.max_scroll_x, 0) + second_pass_visible = {c for c in _visible_flow_keys(footer)} + + self.assertEqual(first_pass_visible, second_pass_visible) + + +class CommandPaletteKeyTest(unittest.IsolatedAsyncioTestCase): + """The docked command-palette key is a fixed fixture: always present + when enabled, its width counted against the budget, never itself a + trim candidate.""" + + async def test_command_palette_key_survives_heavy_trimming(self) -> None: + app_cls = make_app(24, show_command_palette=True) + app = app_cls() + async with app.run_test(size=(40, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + + self.assertEqual(footer.max_scroll_x, 0) + docked = _docked_keys(footer) + self.assertEqual(len(docked), 1) + self.assertTrue(docked[0].display) + self.assertGreater(len(_hidden_flow_keys(footer)), 0) + + async def test_disabled_command_palette_leaves_no_docked_key(self) -> None: + app_cls = make_app(24, show_command_palette=False) + app = app_cls() + async with app.run_test(size=(40, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + + self.assertEqual(footer.max_scroll_x, 0) + self.assertEqual(len(_docked_keys(footer)), 0) + + async def test_command_palette_width_is_budgeted_not_overlapped(self) -> None: + """Regression for the actual failure mode: a docked key with real + width must shrink the flow's usable budget, not be ignored.""" + app_cls = make_app(24, show_command_palette=True) + app = app_cls() + async with app.run_test(size=(60, 24)) as pilot: + await pilot.pause() + footer = app.query_one(KitFooter) + with_palette_visible = len(_visible_flow_keys(footer)) + + app_cls_no_palette = make_app(24, show_command_palette=False) + app2 = app_cls_no_palette() + async with app2.run_test(size=(60, 24)) as pilot: + await pilot.pause() + footer2 = app2.query_one(KitFooter) + without_palette_visible = len(_visible_flow_keys(footer2)) + + self.assertLessEqual(with_palette_visible, without_palette_visible) + + +class KitFooterDefaultsTest(unittest.IsolatedAsyncioTestCase): + """KitFooter matches Footer's constructor but flips the compact default, + per DESIGN.md's minimalist doctrine.""" + + async def test_compact_defaults_true(self) -> None: + footer = KitFooter() + self.assertTrue(footer.compact) + + async def test_compact_can_still_be_overridden(self) -> None: + footer = KitFooter(compact=False) + self.assertFalse(footer.compact) + + +if __name__ == "__main__": + unittest.main() From 58c76010fe1b0d08f2c2168394abe43d14256a20 Mon Sep 17 00:00:00 2001 From: Gheat Date: Wed, 22 Jul 2026 16:40:16 -0700 Subject: [PATCH 2/2] tests: settle fully before asserting, compare footer keys by identity not object Two issues surfaced running the suite on the full CI matrix: - A single pilot.pause() only drains one call_after_refresh hop. Getting a footer to its steady state after mount or resize is several hops deep (screen's bindings-updated signal -> bindings_changed -> recompose -> KitFooter's own overflow check chained onto the same refresh), which a fast local machine absorbs in one pause but a loaded CI runner does not - windows-latest in particular was asserting against a footer that hadn't finished composing yet. Pausing a handful of times drains the whole chain deterministically instead of asserting mid-flight. - The narrow/wide/narrow round-trip test compared visible children by widget identity. A recompose mounts fresh FooterKey instances for the same bindings, so two settle points holding the same visible keys could still legitimately be disjoint sets of widget objects. Compare by key_display instead, which is stable across a recompose. --- tests/test_footer.py | 55 +++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/tests/test_footer.py b/tests/test_footer.py index 09a938d..2df26c8 100644 --- a/tests/test_footer.py +++ b/tests/test_footer.py @@ -24,6 +24,22 @@ WIDTHS = (40, 60, 80, 120) +async def _settle(pilot) -> None: + """Wait for the footer to reach its steady state after a mount or resize. + + Populating the footer and then checking it for overflow is a chain of + several `call_after_refresh` hops: the screen's bindings-updated signal + triggers `bindings_changed`, which schedules a recompose; the recompose + mounts the real FooterKey children; KitFooter chains its own overflow + check onto that same refresh. `pilot.pause()` only guarantees draining + *one* such hop, which is plenty on a fast machine but not reliably + enough on a loaded CI runner. Pausing a few times drains the whole + chain deterministically instead of asserting mid-flight. + """ + for _ in range(5): + await pilot.pause() + + def _visible_flow_keys(footer: KitFooter) -> list: """Non-docked children currently displayed (i.e. not trimmed).""" return [c for c in footer.children if c.display and c.styles.dock == "none"] @@ -38,6 +54,17 @@ def _docked_keys(footer: KitFooter) -> list: return [c for c in footer.children if c.styles.dock != "none"] +def _visible_key_displays(footer: KitFooter) -> set[str]: + """Stable identity for a visible flow key: its key display string. + + Not widget identity — a recompose (e.g. from a bindings change) mounts + brand-new FooterKey instances for the same bindings, so comparing raw + widgets across two separate settle points would spuriously "differ" + even when the same keys are showing. + """ + return {c.key_display for c in _visible_flow_keys(footer)} + + def make_app(n: int, show_command_palette: bool = True) -> type[App]: """Build a throwaway App class with `n` distinct, always-shown bindings.""" @@ -65,7 +92,7 @@ async def test_fits_without_hiding_anything(self) -> None: app_cls = make_app(6) app = app_cls() async with app.run_test(size=(80, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) self.assertEqual(footer.max_scroll_x, 0) @@ -83,7 +110,7 @@ async def test_never_overflows_across_widths(self) -> None: with self.subTest(width=width): app = app_cls() async with app.run_test(size=(width, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) self.assertEqual( @@ -101,7 +128,7 @@ async def test_narrower_width_hides_at_least_as_much(self) -> None: for width in WIDTHS: app = app_cls() async with app.run_test(size=(width, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) visible_counts[width] = len(_visible_flow_keys(footer)) @@ -122,7 +149,7 @@ async def test_widening_reveals_previously_hidden_keys(self) -> None: app_cls = make_app(24) app = app_cls() async with app.run_test(size=(40, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) self.assertEqual(footer.max_scroll_x, 0) narrow_visible = len(_visible_flow_keys(footer)) @@ -130,7 +157,7 @@ async def test_widening_reveals_previously_hidden_keys(self) -> None: self.assertGreater(narrow_hidden, 0) await pilot.resize_terminal(120, 24) - await pilot.pause() + await _settle(pilot) self.assertEqual(footer.max_scroll_x, 0) wide_visible = len(_visible_flow_keys(footer)) @@ -142,18 +169,18 @@ async def test_round_trip_narrow_wide_narrow(self) -> None: app_cls = make_app(24) app = app_cls() async with app.run_test(size=(40, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) - first_pass_visible = {c for c in _visible_flow_keys(footer)} + first_pass_visible = _visible_key_displays(footer) await pilot.resize_terminal(120, 24) - await pilot.pause() + await _settle(pilot) self.assertEqual(footer.max_scroll_x, 0) await pilot.resize_terminal(40, 24) - await pilot.pause() + await _settle(pilot) self.assertEqual(footer.max_scroll_x, 0) - second_pass_visible = {c for c in _visible_flow_keys(footer)} + second_pass_visible = _visible_key_displays(footer) self.assertEqual(first_pass_visible, second_pass_visible) @@ -167,7 +194,7 @@ async def test_command_palette_key_survives_heavy_trimming(self) -> None: app_cls = make_app(24, show_command_palette=True) app = app_cls() async with app.run_test(size=(40, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) self.assertEqual(footer.max_scroll_x, 0) @@ -180,7 +207,7 @@ async def test_disabled_command_palette_leaves_no_docked_key(self) -> None: app_cls = make_app(24, show_command_palette=False) app = app_cls() async with app.run_test(size=(40, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) self.assertEqual(footer.max_scroll_x, 0) @@ -192,14 +219,14 @@ async def test_command_palette_width_is_budgeted_not_overlapped(self) -> None: app_cls = make_app(24, show_command_palette=True) app = app_cls() async with app.run_test(size=(60, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer = app.query_one(KitFooter) with_palette_visible = len(_visible_flow_keys(footer)) app_cls_no_palette = make_app(24, show_command_palette=False) app2 = app_cls_no_palette() async with app2.run_test(size=(60, 24)) as pilot: - await pilot.pause() + await _settle(pilot) footer2 = app2.query_one(KitFooter) without_palette_visible = len(_visible_flow_keys(footer2))