ricekit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ricekit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A developer's TUI suite for Textual — themes, widgets, modals, icons, and the design system behind ltui, jtui, and sctui
5
5
  Author: Gheat
6
6
  License: MIT
@@ -44,7 +44,7 @@ everything a fast, clean, rice-friendly terminal app needs, minus the app.
44
44
  | `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 |
45
45
  | `ricekit.palette` | swappable chrome palette (`palette.text`, `.dim`, `.blue`, …) that flips to terminal ANSI colors under the `system` theme |
46
46
  | `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 |
47
- | `ricekit.widgets` | `NavList` (vim keys, quiet cursor), `Splitter` (drag-to-resize with persistence hook, double-click reset), `KitScroll`, `pop_in` (the one sanctioned animation) |
47
+ | `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) |
48
48
  | `ricekit.modals` | `PickerModal` (generic chooser), `ThemeModal` (restyles the app live as you scroll), `HelpModal` (keybinding cheatsheet from plain data) |
49
49
  | `ricekit.icons` | curated nerd-font icons as `\uXXXX` escapes + unicode state glyphs (◌ ○ ◐ ◑ ● ⊘) + mini bar gauges |
50
50
  | `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 |
@@ -70,11 +70,10 @@ uv add git+https://github.com/Gheat1/ricekit # or pip install git+…
70
70
 
71
71
  ```python
72
72
  from textual.binding import Binding
73
- from textual.widgets import Footer
74
73
 
75
74
  from ricekit import KitApp, icons, palette
76
75
  from ricekit.storage import AppDirs
77
- from ricekit.widgets import NavList
76
+ from ricekit.widgets import KitFooter, NavList
78
77
 
79
78
  DIRS = AppDirs("myapp")
80
79
 
@@ -87,7 +86,7 @@ class MyApp(KitApp):
87
86
 
88
87
  def compose(self):
89
88
  yield NavList(id="items")
90
- yield Footer()
89
+ yield KitFooter()
91
90
 
92
91
  def on_mount(self):
93
92
  self.init_kit(theme=DIRS.load_state().get("theme"))
@@ -22,7 +22,7 @@ everything a fast, clean, rice-friendly terminal app needs, minus the app.
22
22
  | `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 |
23
23
  | `ricekit.palette` | swappable chrome palette (`palette.text`, `.dim`, `.blue`, …) that flips to terminal ANSI colors under the `system` theme |
24
24
  | `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 |
25
- | `ricekit.widgets` | `NavList` (vim keys, quiet cursor), `Splitter` (drag-to-resize with persistence hook, double-click reset), `KitScroll`, `pop_in` (the one sanctioned animation) |
25
+ | `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) |
26
26
  | `ricekit.modals` | `PickerModal` (generic chooser), `ThemeModal` (restyles the app live as you scroll), `HelpModal` (keybinding cheatsheet from plain data) |
27
27
  | `ricekit.icons` | curated nerd-font icons as `\uXXXX` escapes + unicode state glyphs (◌ ○ ◐ ◑ ● ⊘) + mini bar gauges |
28
28
  | `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+…
48
48
 
49
49
  ```python
50
50
  from textual.binding import Binding
51
- from textual.widgets import Footer
52
51
 
53
52
  from ricekit import KitApp, icons, palette
54
53
  from ricekit.storage import AppDirs
55
- from ricekit.widgets import NavList
54
+ from ricekit.widgets import KitFooter, NavList
56
55
 
57
56
  DIRS = AppDirs("myapp")
58
57
 
@@ -65,7 +64,7 @@ class MyApp(KitApp):
65
64
 
66
65
  def compose(self):
67
66
  yield NavList(id="items")
68
- yield Footer()
67
+ yield KitFooter()
69
68
 
70
69
  def on_mount(self):
71
70
  self.init_kit(theme=DIRS.load_state().get("theme"))
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ricekit"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A developer's TUI suite for Textual — themes, widgets, modals, icons, and the design system behind ltui, jtui, and sctui"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -6,7 +6,7 @@ siblings jtui (Jira) and sctui (Shortcut): everything a fast,
6
6
  clean, rice-friendly terminal app needs, minus the app.
7
7
 
8
8
  from ricekit import KitApp, palette, icons
9
- from ricekit.widgets import NavList, Splitter, KitScroll, pop_in
9
+ from ricekit.widgets import NavList, Splitter, KitScroll, KitFooter, pop_in
10
10
  from ricekit.modals import PickerModal, ThemeModal, HelpModal
11
11
  from ricekit.storage import AppDirs
12
12
 
@@ -15,7 +15,7 @@ See DESIGN.md for the philosophy and the sharp edges.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- __version__ = "0.2.0"
18
+ __version__ = "0.3.0"
19
19
 
20
20
  from ricekit import fx, icons
21
21
  from ricekit.app import KitApp
@@ -0,0 +1,239 @@
1
+ """Reusable widgets: vim-navigable lists, drag-to-resize splitters, an
2
+ overflow-safe footer, motion."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING, Callable
7
+
8
+ from textual import events
9
+ from textual.binding import Binding
10
+ from textual.containers import VerticalScroll
11
+ from textual.widget import Widget
12
+ from textual.widgets import Footer, OptionList, Static
13
+
14
+ if TYPE_CHECKING:
15
+ from textual.screen import Screen
16
+
17
+
18
+ def pop_in(widget, duration: float = 0.15) -> None:
19
+ """Fade a freshly mounted container into place.
20
+
21
+ Opacity only, on purpose: offset/slide animation isn't supported for
22
+ ScalarOffset in textual 8.x. 150ms out_cubic still reads as motion.
23
+ """
24
+ widget.styles.opacity = 0.0
25
+ widget.styles.animate("opacity", 1.0, duration=duration, easing="out_cubic")
26
+
27
+
28
+ class NavList(OptionList):
29
+ """OptionList with vim navigation. Use disabled options as group headers —
30
+ keyboard navigation skips them automatically."""
31
+
32
+ BINDINGS = [
33
+ Binding("j", "cursor_down", show=False),
34
+ Binding("k", "cursor_up", show=False),
35
+ Binding("g", "first", show=False),
36
+ Binding("G", "last", show=False),
37
+ ]
38
+
39
+ DEFAULT_CSS = """
40
+ NavList {
41
+ background: transparent;
42
+ border: none;
43
+ padding: 0 1;
44
+ scrollbar-size-vertical: 1;
45
+ }
46
+ NavList:focus { background: transparent; border: none; }
47
+ NavList > .option-list--option-highlighted { background: $kit-cursor; }
48
+ NavList:focus > .option-list--option-highlighted { background: $kit-cursor; }
49
+ """
50
+
51
+
52
+ class KitScroll(VerticalScroll):
53
+ """Focusable scroll container with vim keys (detail panes, docs, logs).
54
+
55
+ Anything you mount into this dynamically MUST have `height: auto` in CSS —
56
+ containers default to fr-height and make the scroll area under-measure
57
+ its content (scrolling then stops before the end).
58
+ """
59
+
60
+ can_focus = True
61
+ BINDINGS = [
62
+ Binding("j", "scroll_down", show=False),
63
+ Binding("k", "scroll_up", show=False),
64
+ ]
65
+
66
+ DEFAULT_CSS = """
67
+ KitScroll { scrollbar-size-vertical: 1; }
68
+ """
69
+
70
+
71
+ class Splitter(Static):
72
+ """A full-height drag handle between panels.
73
+
74
+ Drag to resize `target` (a CSS selector); double-click resets to the
75
+ stylesheet default. Pass `on_resized(target_selector, width_or_None)`
76
+ to persist layout. `invert=True` for handles on the *left* edge of the
77
+ panel they resize (dragging left grows it).
78
+ """
79
+
80
+ can_focus = False
81
+ ALLOW_SELECT = False # a drag here resizes; it must not start text selection
82
+
83
+ DEFAULT_CSS = """
84
+ Splitter { width: 1; height: 1fr; }
85
+ Splitter:hover, Splitter.dragging { background: $kit-border; }
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ target: str,
91
+ invert: bool = False,
92
+ min_width: int = 16,
93
+ max_width: int = 100,
94
+ on_resized: Callable[[str, int | None], None] | None = None,
95
+ **kwargs,
96
+ ) -> None:
97
+ super().__init__(**kwargs)
98
+ self._target = target
99
+ self._invert = invert
100
+ self._min = min_width
101
+ self._max = max_width
102
+ self._on_resized = on_resized
103
+ self._drag_x: int | None = None
104
+ self._start_w: int = 0
105
+
106
+ def on_mouse_down(self, event) -> None:
107
+ # outer_size, not size: `size` is the content box, so bordered
108
+ # targets would drift by the border width every drag
109
+ self._drag_x = event.screen_x
110
+ self._start_w = self.app.query_one(self._target).outer_size.width
111
+ self.capture_mouse()
112
+ self.add_class("dragging")
113
+
114
+ def on_mouse_move(self, event) -> None:
115
+ if self._drag_x is None:
116
+ return
117
+ delta = event.screen_x - self._drag_x
118
+ if self._invert:
119
+ delta = -delta
120
+ cap = min(self._max, self.app.size.width - 50)
121
+ width = max(self._min, min(self._start_w + delta, cap))
122
+ self.app.query_one(self._target).styles.width = width
123
+
124
+ def on_mouse_up(self, event) -> None:
125
+ if self._drag_x is None:
126
+ return
127
+ self._drag_x = None
128
+ self.release_mouse()
129
+ self.remove_class("dragging")
130
+ if self._on_resized is not None:
131
+ width = self.app.query_one(self._target).outer_size.width
132
+ self._on_resized(self._target, width)
133
+
134
+ def on_click(self, event) -> None:
135
+ if getattr(event, "chain", 1) == 2:
136
+ self.app.query_one(self._target).styles.width = None
137
+ if self._on_resized is not None:
138
+ self._on_resized(self._target, None)
139
+
140
+
141
+ class KitFooter(Footer):
142
+ """A `Footer` that guarantees zero horizontal overflow.
143
+
144
+ DESIGN.md's doctrine is "Footer shows the ~7 keys that matter" — every
145
+ app hand-picks which of its own `Binding`s get `show=True` and hopes
146
+ the count and the terminal width cooperate. Stock `Footer` is a plain
147
+ `ScrollableContainer` with an invisible scrollbar
148
+ (`scrollbar-size: 0 0`), so when they don't cooperate the excess just
149
+ scrolls off screen with no visual indication anything is missing.
150
+
151
+ This subclass composes exactly like stock `Footer` — same children,
152
+ same CSS, `compact=True` by default instead of `False` — then, once
153
+ layout is known (on mount and on every resize), measures the real
154
+ arranged width of its children against the available width. If it
155
+ doesn't fit, it hides (never removes) trailing children in DOM order —
156
+ the ones from bindings declared latest in `BINDINGS`, the same
157
+ priority stock `Footer` already renders in — one at a time, until the
158
+ rest fits. Widening the terminal reveals hidden keys again; nothing is
159
+ ever a one-way hide. The docked command-palette key is budgeted for
160
+ but is never itself a trim candidate.
161
+
162
+ This does not try to show more than the app marked `show=True` — it
163
+ only ever hides. An app that already fits inside ~7 keys should see
164
+ this do nothing at any reasonable width.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ *children: Widget,
170
+ name: str | None = None,
171
+ id: str | None = None,
172
+ classes: str | None = None,
173
+ disabled: bool = False,
174
+ show_command_palette: bool = True,
175
+ compact: bool = True,
176
+ ) -> None:
177
+ super().__init__(
178
+ *children,
179
+ name=name,
180
+ id=id,
181
+ classes=classes,
182
+ disabled=disabled,
183
+ show_command_palette=show_command_palette,
184
+ compact=compact,
185
+ )
186
+
187
+ def on_mount(self) -> None:
188
+ super().on_mount()
189
+ self.call_after_refresh(self._enforce_no_overflow)
190
+
191
+ def on_resize(self, event: events.Resize) -> None:
192
+ self._enforce_no_overflow()
193
+
194
+ def bindings_changed(self, screen: Screen) -> None:
195
+ # Fires on mount (once bindings are known) and whenever the active
196
+ # bindings change; the base class recomposes after a refresh — chain
197
+ # our check onto the same refresh so it sees the real children.
198
+ super().bindings_changed(screen)
199
+ self.call_after_refresh(self._enforce_no_overflow)
200
+
201
+ def _enforce_no_overflow(self) -> None:
202
+ """Trim trailing flow children until nothing overflows.
203
+
204
+ `self.arrange()` gives a real `DockArrangeResult` computed from the
205
+ current DOM — the same math Textual itself uses to derive
206
+ `virtual_size`/`max_scroll_x` — without waiting on the reactive
207
+ pipeline to catch up, so this can run synchronously in a tight
208
+ loop. The arrangement cache is keyed on child count, not on
209
+ `display`, so it has to be cleared by hand after every toggle.
210
+ """
211
+ if not self.is_mounted or not self.is_attached:
212
+ return
213
+ size = self.size
214
+ if size.width <= 0:
215
+ return
216
+
217
+ # Only children in normal flow are trim candidates — the docked
218
+ # command-palette key is a fixed fixture, budgeted for below but
219
+ # never hidden.
220
+ candidates = [child for child in self.children if child.styles.dock == "none"]
221
+ if not candidates:
222
+ return
223
+
224
+ # Un-hide everything first: this is what lets a resize back to a
225
+ # wider terminal bring previously-hidden keys back, instead of
226
+ # leaving them hidden forever.
227
+ if any(not child.display for child in candidates):
228
+ for child in candidates:
229
+ child.display = True
230
+ self._clear_arrangement_cache()
231
+
232
+ # Hide from the end — lowest priority, latest in BINDINGS — one at
233
+ # a time, re-measuring after each, until the rest fits.
234
+ for child in reversed(candidates):
235
+ self._clear_arrangement_cache()
236
+ if self.arrange(size).total_region.width <= size.width:
237
+ return
238
+ child.display = False
239
+ self._clear_arrangement_cache()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ricekit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A developer's TUI suite for Textual — themes, widgets, modals, icons, and the design system behind ltui, jtui, and sctui
5
5
  Author: Gheat
6
6
  License: MIT
@@ -44,7 +44,7 @@ everything a fast, clean, rice-friendly terminal app needs, minus the app.
44
44
  | `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 |
45
45
  | `ricekit.palette` | swappable chrome palette (`palette.text`, `.dim`, `.blue`, …) that flips to terminal ANSI colors under the `system` theme |
46
46
  | `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 |
47
- | `ricekit.widgets` | `NavList` (vim keys, quiet cursor), `Splitter` (drag-to-resize with persistence hook, double-click reset), `KitScroll`, `pop_in` (the one sanctioned animation) |
47
+ | `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) |
48
48
  | `ricekit.modals` | `PickerModal` (generic chooser), `ThemeModal` (restyles the app live as you scroll), `HelpModal` (keybinding cheatsheet from plain data) |
49
49
  | `ricekit.icons` | curated nerd-font icons as `\uXXXX` escapes + unicode state glyphs (◌ ○ ◐ ◑ ● ⊘) + mini bar gauges |
50
50
  | `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 |
@@ -70,11 +70,10 @@ uv add git+https://github.com/Gheat1/ricekit # or pip install git+…
70
70
 
71
71
  ```python
72
72
  from textual.binding import Binding
73
- from textual.widgets import Footer
74
73
 
75
74
  from ricekit import KitApp, icons, palette
76
75
  from ricekit.storage import AppDirs
77
- from ricekit.widgets import NavList
76
+ from ricekit.widgets import KitFooter, NavList
78
77
 
79
78
  DIRS = AppDirs("myapp")
80
79
 
@@ -87,7 +86,7 @@ class MyApp(KitApp):
87
86
 
88
87
  def compose(self):
89
88
  yield NavList(id="items")
90
- yield Footer()
89
+ yield KitFooter()
91
90
 
92
91
  def on_mount(self):
93
92
  self.init_kit(theme=DIRS.load_state().get("theme"))
@@ -16,4 +16,5 @@ ricekit.egg-info/SOURCES.txt
16
16
  ricekit.egg-info/dependency_links.txt
17
17
  ricekit.egg-info/entry_points.txt
18
18
  ricekit.egg-info/requires.txt
19
- ricekit.egg-info/top_level.txt
19
+ ricekit.egg-info/top_level.txt
20
+ tests/test_footer.py
@@ -0,0 +1,250 @@
1
+ """Regression tests for KitFooter's zero-horizontal-overflow guarantee.
2
+
3
+ tuistore shipped a Footer with 12 `show=True` bindings — nowhere near the
4
+ DESIGN.md "~7 keys" target — and at 80 columns the stock Textual `Footer`
5
+ (a plain `ScrollableContainer` with `scrollbar-size: 0 0`, i.e. an
6
+ invisible scrollbar) silently overflowed by 46 columns with no visual sign
7
+ anything was missing. tuistore's fix was a hand-tuned `show=False` pass on
8
+ five bindings — a static, per-app workaround with nothing stopping the same
9
+ mistake next time a binding gets added, in tuistore or any other app on
10
+ ricekit. These tests hold KitFooter to the invariant that fix didn't
11
+ provide: `max_scroll_x` is 0 no matter how many bindings are marked
12
+ `show=True` or how narrow the terminal gets.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import unittest
18
+
19
+ from textual.app import App, ComposeResult
20
+ from textual.binding import Binding
21
+
22
+ from ricekit.widgets import KitFooter
23
+
24
+ WIDTHS = (40, 60, 80, 120)
25
+
26
+
27
+ async def _settle(pilot) -> None:
28
+ """Wait for the footer to reach its steady state after a mount or resize.
29
+
30
+ Populating the footer and then checking it for overflow is a chain of
31
+ several `call_after_refresh` hops: the screen's bindings-updated signal
32
+ triggers `bindings_changed`, which schedules a recompose; the recompose
33
+ mounts the real FooterKey children; KitFooter chains its own overflow
34
+ check onto that same refresh. `pilot.pause()` only guarantees draining
35
+ *one* such hop, which is plenty on a fast machine but not reliably
36
+ enough on a loaded CI runner. Pausing a few times drains the whole
37
+ chain deterministically instead of asserting mid-flight.
38
+ """
39
+ for _ in range(5):
40
+ await pilot.pause()
41
+
42
+
43
+ def _visible_flow_keys(footer: KitFooter) -> list:
44
+ """Non-docked children currently displayed (i.e. not trimmed)."""
45
+ return [c for c in footer.children if c.display and c.styles.dock == "none"]
46
+
47
+
48
+ def _hidden_flow_keys(footer: KitFooter) -> list:
49
+ """Non-docked children currently trimmed (hidden, not removed)."""
50
+ return [c for c in footer.children if not c.display and c.styles.dock == "none"]
51
+
52
+
53
+ def _docked_keys(footer: KitFooter) -> list:
54
+ return [c for c in footer.children if c.styles.dock != "none"]
55
+
56
+
57
+ def _visible_key_displays(footer: KitFooter) -> set[str]:
58
+ """Stable identity for a visible flow key: its key display string.
59
+
60
+ Not widget identity — a recompose (e.g. from a bindings change) mounts
61
+ brand-new FooterKey instances for the same bindings, so comparing raw
62
+ widgets across two separate settle points would spuriously "differ"
63
+ even when the same keys are showing.
64
+ """
65
+ return {c.key_display for c in _visible_flow_keys(footer)}
66
+
67
+
68
+ def make_app(n: int, show_command_palette: bool = True) -> type[App]:
69
+ """Build a throwaway App class with `n` distinct, always-shown bindings."""
70
+
71
+ bindings = [
72
+ Binding(str(i) if i < 10 else chr(ord("a") + i - 10), f"act{i}", f"action {i}", show=True)
73
+ for i in range(n)
74
+ ]
75
+
76
+ class FooterHarness(App):
77
+ BINDINGS = bindings
78
+
79
+ def compose(self) -> ComposeResult:
80
+ yield KitFooter(show_command_palette=show_command_palette)
81
+
82
+ for i in range(n):
83
+ setattr(FooterHarness, f"action_act{i}", lambda self: None)
84
+
85
+ return FooterHarness
86
+
87
+
88
+ class SmallBindingSetTest(unittest.IsolatedAsyncioTestCase):
89
+ """A well-under-budget binding set (~6 keys) needs no trimming at all."""
90
+
91
+ async def test_fits_without_hiding_anything(self) -> None:
92
+ app_cls = make_app(6)
93
+ app = app_cls()
94
+ async with app.run_test(size=(80, 24)) as pilot:
95
+ await _settle(pilot)
96
+ footer = app.query_one(KitFooter)
97
+
98
+ self.assertEqual(footer.max_scroll_x, 0)
99
+ self.assertEqual(len(_hidden_flow_keys(footer)), 0)
100
+ self.assertEqual(len(_visible_flow_keys(footer)), 6)
101
+
102
+
103
+ class ExcessiveBindingSetTest(unittest.IsolatedAsyncioTestCase):
104
+ """Far more bindings than could ever fit — the actual tuistore scenario,
105
+ exaggerated. Zero overflow must hold at every width tested."""
106
+
107
+ async def test_never_overflows_across_widths(self) -> None:
108
+ app_cls = make_app(24)
109
+ for width in WIDTHS:
110
+ with self.subTest(width=width):
111
+ app = app_cls()
112
+ async with app.run_test(size=(width, 24)) as pilot:
113
+ await _settle(pilot)
114
+ footer = app.query_one(KitFooter)
115
+
116
+ self.assertEqual(
117
+ footer.max_scroll_x,
118
+ 0,
119
+ f"footer overflowed at width={width}",
120
+ )
121
+ # 24 bindings can't possibly fit even at 120 columns —
122
+ # something must have been trimmed everywhere.
123
+ self.assertGreater(len(_hidden_flow_keys(footer)), 0)
124
+
125
+ async def test_narrower_width_hides_at_least_as_much(self) -> None:
126
+ app_cls = make_app(24)
127
+ visible_counts = {}
128
+ for width in WIDTHS:
129
+ app = app_cls()
130
+ async with app.run_test(size=(width, 24)) as pilot:
131
+ await _settle(pilot)
132
+ footer = app.query_one(KitFooter)
133
+ visible_counts[width] = len(_visible_flow_keys(footer))
134
+
135
+ ordered = sorted(visible_counts)
136
+ for narrower, wider in zip(ordered, ordered[1:]):
137
+ self.assertLessEqual(
138
+ visible_counts[narrower],
139
+ visible_counts[wider],
140
+ f"width={narrower} showed more keys than width={wider}",
141
+ )
142
+
143
+
144
+ class ResizeRevealTest(unittest.IsolatedAsyncioTestCase):
145
+ """Hiding is not one-way: widening the terminal must bring back keys
146
+ that were trimmed at a narrower size, once they fit again."""
147
+
148
+ async def test_widening_reveals_previously_hidden_keys(self) -> None:
149
+ app_cls = make_app(24)
150
+ app = app_cls()
151
+ async with app.run_test(size=(40, 24)) as pilot:
152
+ await _settle(pilot)
153
+ footer = app.query_one(KitFooter)
154
+ self.assertEqual(footer.max_scroll_x, 0)
155
+ narrow_visible = len(_visible_flow_keys(footer))
156
+ narrow_hidden = len(_hidden_flow_keys(footer))
157
+ self.assertGreater(narrow_hidden, 0)
158
+
159
+ await pilot.resize_terminal(120, 24)
160
+ await _settle(pilot)
161
+
162
+ self.assertEqual(footer.max_scroll_x, 0)
163
+ wide_visible = len(_visible_flow_keys(footer))
164
+ self.assertGreater(wide_visible, narrow_visible)
165
+
166
+ async def test_round_trip_narrow_wide_narrow(self) -> None:
167
+ """Shrink, grow, shrink back — the same keys should be hidden as
168
+ the first time at that width, not accumulate stale state."""
169
+ app_cls = make_app(24)
170
+ app = app_cls()
171
+ async with app.run_test(size=(40, 24)) as pilot:
172
+ await _settle(pilot)
173
+ footer = app.query_one(KitFooter)
174
+ first_pass_visible = _visible_key_displays(footer)
175
+
176
+ await pilot.resize_terminal(120, 24)
177
+ await _settle(pilot)
178
+ self.assertEqual(footer.max_scroll_x, 0)
179
+
180
+ await pilot.resize_terminal(40, 24)
181
+ await _settle(pilot)
182
+ self.assertEqual(footer.max_scroll_x, 0)
183
+ second_pass_visible = _visible_key_displays(footer)
184
+
185
+ self.assertEqual(first_pass_visible, second_pass_visible)
186
+
187
+
188
+ class CommandPaletteKeyTest(unittest.IsolatedAsyncioTestCase):
189
+ """The docked command-palette key is a fixed fixture: always present
190
+ when enabled, its width counted against the budget, never itself a
191
+ trim candidate."""
192
+
193
+ async def test_command_palette_key_survives_heavy_trimming(self) -> None:
194
+ app_cls = make_app(24, show_command_palette=True)
195
+ app = app_cls()
196
+ async with app.run_test(size=(40, 24)) as pilot:
197
+ await _settle(pilot)
198
+ footer = app.query_one(KitFooter)
199
+
200
+ self.assertEqual(footer.max_scroll_x, 0)
201
+ docked = _docked_keys(footer)
202
+ self.assertEqual(len(docked), 1)
203
+ self.assertTrue(docked[0].display)
204
+ self.assertGreater(len(_hidden_flow_keys(footer)), 0)
205
+
206
+ async def test_disabled_command_palette_leaves_no_docked_key(self) -> None:
207
+ app_cls = make_app(24, show_command_palette=False)
208
+ app = app_cls()
209
+ async with app.run_test(size=(40, 24)) as pilot:
210
+ await _settle(pilot)
211
+ footer = app.query_one(KitFooter)
212
+
213
+ self.assertEqual(footer.max_scroll_x, 0)
214
+ self.assertEqual(len(_docked_keys(footer)), 0)
215
+
216
+ async def test_command_palette_width_is_budgeted_not_overlapped(self) -> None:
217
+ """Regression for the actual failure mode: a docked key with real
218
+ width must shrink the flow's usable budget, not be ignored."""
219
+ app_cls = make_app(24, show_command_palette=True)
220
+ app = app_cls()
221
+ async with app.run_test(size=(60, 24)) as pilot:
222
+ await _settle(pilot)
223
+ footer = app.query_one(KitFooter)
224
+ with_palette_visible = len(_visible_flow_keys(footer))
225
+
226
+ app_cls_no_palette = make_app(24, show_command_palette=False)
227
+ app2 = app_cls_no_palette()
228
+ async with app2.run_test(size=(60, 24)) as pilot:
229
+ await _settle(pilot)
230
+ footer2 = app2.query_one(KitFooter)
231
+ without_palette_visible = len(_visible_flow_keys(footer2))
232
+
233
+ self.assertLessEqual(with_palette_visible, without_palette_visible)
234
+
235
+
236
+ class KitFooterDefaultsTest(unittest.IsolatedAsyncioTestCase):
237
+ """KitFooter matches Footer's constructor but flips the compact default,
238
+ per DESIGN.md's minimalist doctrine."""
239
+
240
+ async def test_compact_defaults_true(self) -> None:
241
+ footer = KitFooter()
242
+ self.assertTrue(footer.compact)
243
+
244
+ async def test_compact_can_still_be_overridden(self) -> None:
245
+ footer = KitFooter(compact=False)
246
+ self.assertFalse(footer.compact)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ unittest.main()
@@ -1,132 +0,0 @@
1
- """Reusable widgets: vim-navigable lists, drag-to-resize splitters, motion."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Callable
6
-
7
- from textual.binding import Binding
8
- from textual.containers import VerticalScroll
9
- from textual.widgets import OptionList, Static
10
-
11
-
12
- def pop_in(widget, duration: float = 0.15) -> None:
13
- """Fade a freshly mounted container into place.
14
-
15
- Opacity only, on purpose: offset/slide animation isn't supported for
16
- ScalarOffset in textual 8.x. 150ms out_cubic still reads as motion.
17
- """
18
- widget.styles.opacity = 0.0
19
- widget.styles.animate("opacity", 1.0, duration=duration, easing="out_cubic")
20
-
21
-
22
- class NavList(OptionList):
23
- """OptionList with vim navigation. Use disabled options as group headers —
24
- keyboard navigation skips them automatically."""
25
-
26
- BINDINGS = [
27
- Binding("j", "cursor_down", show=False),
28
- Binding("k", "cursor_up", show=False),
29
- Binding("g", "first", show=False),
30
- Binding("G", "last", show=False),
31
- ]
32
-
33
- DEFAULT_CSS = """
34
- NavList {
35
- background: transparent;
36
- border: none;
37
- padding: 0 1;
38
- scrollbar-size-vertical: 1;
39
- }
40
- NavList:focus { background: transparent; border: none; }
41
- NavList > .option-list--option-highlighted { background: $kit-cursor; }
42
- NavList:focus > .option-list--option-highlighted { background: $kit-cursor; }
43
- """
44
-
45
-
46
- class KitScroll(VerticalScroll):
47
- """Focusable scroll container with vim keys (detail panes, docs, logs).
48
-
49
- Anything you mount into this dynamically MUST have `height: auto` in CSS —
50
- containers default to fr-height and make the scroll area under-measure
51
- its content (scrolling then stops before the end).
52
- """
53
-
54
- can_focus = True
55
- BINDINGS = [
56
- Binding("j", "scroll_down", show=False),
57
- Binding("k", "scroll_up", show=False),
58
- ]
59
-
60
- DEFAULT_CSS = """
61
- KitScroll { scrollbar-size-vertical: 1; }
62
- """
63
-
64
-
65
- class Splitter(Static):
66
- """A full-height drag handle between panels.
67
-
68
- Drag to resize `target` (a CSS selector); double-click resets to the
69
- stylesheet default. Pass `on_resized(target_selector, width_or_None)`
70
- to persist layout. `invert=True` for handles on the *left* edge of the
71
- panel they resize (dragging left grows it).
72
- """
73
-
74
- can_focus = False
75
- ALLOW_SELECT = False # a drag here resizes; it must not start text selection
76
-
77
- DEFAULT_CSS = """
78
- Splitter { width: 1; height: 1fr; }
79
- Splitter:hover, Splitter.dragging { background: $kit-border; }
80
- """
81
-
82
- def __init__(
83
- self,
84
- target: str,
85
- invert: bool = False,
86
- min_width: int = 16,
87
- max_width: int = 100,
88
- on_resized: Callable[[str, int | None], None] | None = None,
89
- **kwargs,
90
- ) -> None:
91
- super().__init__(**kwargs)
92
- self._target = target
93
- self._invert = invert
94
- self._min = min_width
95
- self._max = max_width
96
- self._on_resized = on_resized
97
- self._drag_x: int | None = None
98
- self._start_w: int = 0
99
-
100
- def on_mouse_down(self, event) -> None:
101
- # outer_size, not size: `size` is the content box, so bordered
102
- # targets would drift by the border width every drag
103
- self._drag_x = event.screen_x
104
- self._start_w = self.app.query_one(self._target).outer_size.width
105
- self.capture_mouse()
106
- self.add_class("dragging")
107
-
108
- def on_mouse_move(self, event) -> None:
109
- if self._drag_x is None:
110
- return
111
- delta = event.screen_x - self._drag_x
112
- if self._invert:
113
- delta = -delta
114
- cap = min(self._max, self.app.size.width - 50)
115
- width = max(self._min, min(self._start_w + delta, cap))
116
- self.app.query_one(self._target).styles.width = width
117
-
118
- def on_mouse_up(self, event) -> None:
119
- if self._drag_x is None:
120
- return
121
- self._drag_x = None
122
- self.release_mouse()
123
- self.remove_class("dragging")
124
- if self._on_resized is not None:
125
- width = self.app.query_one(self._target).outer_size.width
126
- self._on_resized(self._target, width)
127
-
128
- def on_click(self, event) -> None:
129
- if getattr(event, "chain", 1) == 2:
130
- self.app.query_one(self._target).styles.width = None
131
- if self._on_resized is not None:
132
- self._on_resized(self._target, None)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes