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..2df26c8 --- /dev/null +++ b/tests/test_footer.py @@ -0,0 +1,250 @@ +"""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) + + +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"] + + +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 _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.""" + + 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 _settle(pilot) + 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 _settle(pilot) + 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 _settle(pilot) + 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 _settle(pilot) + 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 _settle(pilot) + + 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 _settle(pilot) + footer = app.query_one(KitFooter) + first_pass_visible = _visible_key_displays(footer) + + await pilot.resize_terminal(120, 24) + await _settle(pilot) + self.assertEqual(footer.max_scroll_x, 0) + + await pilot.resize_terminal(40, 24) + await _settle(pilot) + self.assertEqual(footer.max_scroll_x, 0) + second_pass_visible = _visible_key_displays(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 _settle(pilot) + 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 _settle(pilot) + 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 _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 _settle(pilot) + 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()