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.
- {ricekit-0.2.0/ricekit.egg-info → ricekit-0.3.0}/PKG-INFO +4 -5
- {ricekit-0.2.0 → ricekit-0.3.0}/README.md +3 -4
- {ricekit-0.2.0 → ricekit-0.3.0}/pyproject.toml +1 -1
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/__init__.py +2 -2
- ricekit-0.3.0/ricekit/widgets.py +239 -0
- {ricekit-0.2.0 → ricekit-0.3.0/ricekit.egg-info}/PKG-INFO +4 -5
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit.egg-info/SOURCES.txt +2 -1
- ricekit-0.3.0/tests/test_footer.py +250 -0
- ricekit-0.2.0/ricekit/widgets.py +0 -132
- {ricekit-0.2.0 → ricekit-0.3.0}/LICENSE +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/app.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/fx.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/gallery.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/icons.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/modals.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/palette.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/storage.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit/themes.py +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit.egg-info/dependency_links.txt +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit.egg-info/entry_points.txt +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit.egg-info/requires.txt +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/ricekit.egg-info/top_level.txt +0 -0
- {ricekit-0.2.0 → ricekit-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ricekit
|
|
3
|
-
Version: 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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
89
|
+
yield KitFooter()
|
|
91
90
|
|
|
92
91
|
def on_mount(self):
|
|
93
92
|
self.init_kit(theme=DIRS.load_state().get("theme"))
|
|
@@ -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()
|
ricekit-0.2.0/ricekit/widgets.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|