ricekit 0.2.0__py3-none-any.whl
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/__init__.py +36 -0
- ricekit/app.py +86 -0
- ricekit/fx.py +53 -0
- ricekit/gallery.py +203 -0
- ricekit/icons.py +70 -0
- ricekit/modals.py +184 -0
- ricekit/palette.py +66 -0
- ricekit/storage.py +79 -0
- ricekit/themes.py +134 -0
- ricekit/widgets.py +132 -0
- ricekit-0.2.0.dist-info/METADATA +112 -0
- ricekit-0.2.0.dist-info/RECORD +16 -0
- ricekit-0.2.0.dist-info/WHEEL +5 -0
- ricekit-0.2.0.dist-info/entry_points.txt +2 -0
- ricekit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ricekit-0.2.0.dist-info/top_level.txt +1 -0
ricekit/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""ricekit — a developer's TUI suite for Textual.
|
|
2
|
+
|
|
3
|
+
The themes, widgets, modals, icons, and design rules extracted from
|
|
4
|
+
building ltui (https://github.com/Gheat1/ltui) — and now shared by its
|
|
5
|
+
siblings jtui (Jira) and sctui (Shortcut): everything a fast,
|
|
6
|
+
clean, rice-friendly terminal app needs, minus the app.
|
|
7
|
+
|
|
8
|
+
from ricekit import KitApp, palette, icons
|
|
9
|
+
from ricekit.widgets import NavList, Splitter, KitScroll, pop_in
|
|
10
|
+
from ricekit.modals import PickerModal, ThemeModal, HelpModal
|
|
11
|
+
from ricekit.storage import AppDirs
|
|
12
|
+
|
|
13
|
+
See DESIGN.md for the philosophy and the sharp edges.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.2.0"
|
|
19
|
+
|
|
20
|
+
from ricekit import fx, icons
|
|
21
|
+
from ricekit.app import KitApp
|
|
22
|
+
from ricekit.palette import Palette, palette
|
|
23
|
+
from ricekit.storage import AppDirs
|
|
24
|
+
from ricekit.themes import KIT_THEMES, KIT_THEME_NAMES
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"KitApp",
|
|
28
|
+
"Palette",
|
|
29
|
+
"palette",
|
|
30
|
+
"icons",
|
|
31
|
+
"fx",
|
|
32
|
+
"AppDirs",
|
|
33
|
+
"KIT_THEMES",
|
|
34
|
+
"KIT_THEME_NAMES",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
ricekit/app.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""KitApp — the base App that wires the whole kit together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App
|
|
6
|
+
from textual.color import Color
|
|
7
|
+
|
|
8
|
+
from ricekit.modals import ThemeModal
|
|
9
|
+
from ricekit.palette import palette
|
|
10
|
+
from ricekit.themes import KIT_THEMES, KIT_THEME_NAMES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KitApp(App):
|
|
14
|
+
"""App base with the kit's theme machinery.
|
|
15
|
+
|
|
16
|
+
Call `self.init_kit(theme=...)` from your `on_mount`. Then:
|
|
17
|
+
|
|
18
|
+
- the five kit themes are registered and one is active
|
|
19
|
+
- `App.ansi_color` flips automatically for ansi-background themes
|
|
20
|
+
(clear, system, ansi-dark, …) so the terminal background shows
|
|
21
|
+
- the shared `palette` swaps to ANSI colors under the `system` theme
|
|
22
|
+
- `action_change_theme` opens the live-preview ThemeModal (this also
|
|
23
|
+
replaces the command palette's built-in "Change theme" flow)
|
|
24
|
+
- `action_cycle_kit_theme` cycles the five kit themes (bind it to `t`)
|
|
25
|
+
- override `on_kit_theme_changed()` to re-render palette-dependent
|
|
26
|
+
content and persist the choice — check `self.kit_theme_previewing`
|
|
27
|
+
before persisting
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
kit_theme_previewing: bool = False
|
|
31
|
+
|
|
32
|
+
def get_css_variables(self) -> dict[str, str]:
|
|
33
|
+
# kit-* variables must exist even before our themes register (the
|
|
34
|
+
# stylesheet is parsed while the default textual theme is active),
|
|
35
|
+
# and when a non-kit builtin theme is active
|
|
36
|
+
variables = super().get_css_variables()
|
|
37
|
+
theme = next((t for t in KIT_THEMES if t.name == self.theme), KIT_THEMES[0])
|
|
38
|
+
for name, value in theme.variables.items():
|
|
39
|
+
variables.setdefault(name, value)
|
|
40
|
+
return variables
|
|
41
|
+
|
|
42
|
+
def init_kit(self, theme: str | None = None) -> None:
|
|
43
|
+
for t in KIT_THEMES:
|
|
44
|
+
self.register_theme(t)
|
|
45
|
+
self.theme_changed_signal.subscribe(self, self._kit_theme_changed)
|
|
46
|
+
if theme and theme in self.available_themes:
|
|
47
|
+
self.theme = theme
|
|
48
|
+
else:
|
|
49
|
+
self.theme = KIT_THEME_NAMES[0]
|
|
50
|
+
self._kit_apply()
|
|
51
|
+
|
|
52
|
+
def _kit_theme_is_ansi(self) -> bool:
|
|
53
|
+
theme = self.available_themes.get(self.theme)
|
|
54
|
+
if theme is None or theme.background is None:
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
return Color.parse(theme.background).ansi is not None
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def _kit_apply(self) -> None:
|
|
62
|
+
self.ansi_color = self._kit_theme_is_ansi()
|
|
63
|
+
palette.set_ansi(self.theme == "system")
|
|
64
|
+
|
|
65
|
+
def _kit_theme_changed(self, _theme) -> None:
|
|
66
|
+
self._kit_apply()
|
|
67
|
+
self.on_kit_theme_changed()
|
|
68
|
+
|
|
69
|
+
def on_kit_theme_changed(self) -> None:
|
|
70
|
+
"""Override me: re-render Rich-text chrome, persist self.theme
|
|
71
|
+
(skip persisting while `self.kit_theme_previewing` is True)."""
|
|
72
|
+
|
|
73
|
+
def action_cycle_kit_theme(self) -> None:
|
|
74
|
+
idx = (
|
|
75
|
+
KIT_THEME_NAMES.index(self.theme)
|
|
76
|
+
if self.theme in KIT_THEME_NAMES
|
|
77
|
+
else -1
|
|
78
|
+
)
|
|
79
|
+
self.theme = KIT_THEME_NAMES[(idx + 1) % len(KIT_THEME_NAMES)]
|
|
80
|
+
|
|
81
|
+
def action_change_theme(self) -> None:
|
|
82
|
+
# overrides App.action_change_theme, so the command palette's
|
|
83
|
+
# "Change theme" command opens the live-preview picker too
|
|
84
|
+
if isinstance(self.screen, ThemeModal):
|
|
85
|
+
return
|
|
86
|
+
self.push_screen(ThemeModal())
|
ricekit/fx.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Text effects: the letter wave and the braille spinner.
|
|
2
|
+
|
|
3
|
+
One shared ticker at ~8fps (0.12s) drives both; render only while a sweep
|
|
4
|
+
or spin is active — idle frames should cost nothing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
|
|
11
|
+
SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
|
|
12
|
+
|
|
13
|
+
FX_TICK = 0.12 # seconds per animation frame
|
|
14
|
+
FX_REST_TICKS = 26 # pause between wave sweeps (~3s)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def wave_markup(s: str, pos: int, base: str, hi: str) -> str:
|
|
18
|
+
"""One traveling letter, bolded + capitalized: gheatmc -> Gheatmc -> gHeatmc.
|
|
19
|
+
|
|
20
|
+
`pos` is the index of the highlighted letter (-1 or out of range = no
|
|
21
|
+
highlight). Advance pos on a timer; after the sweep, rest FX_REST_TICKS.
|
|
22
|
+
"""
|
|
23
|
+
out = []
|
|
24
|
+
for i, ch in enumerate(s):
|
|
25
|
+
e = escape(ch)
|
|
26
|
+
if i == pos:
|
|
27
|
+
out.append(f"[bold {hi}]{e.upper()}[/]")
|
|
28
|
+
else:
|
|
29
|
+
out.append(f"[{base}]{e}[/]")
|
|
30
|
+
return "".join(out)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Wave:
|
|
34
|
+
"""Sweep/rest state machine for wave_markup.
|
|
35
|
+
|
|
36
|
+
tick() returns True when a re-render is needed this frame.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, length: int, rest_ticks: int = FX_REST_TICKS) -> None:
|
|
40
|
+
self.length = length
|
|
41
|
+
self.rest_ticks = rest_ticks
|
|
42
|
+
self.pos = -1
|
|
43
|
+
self._rest = 0
|
|
44
|
+
|
|
45
|
+
def tick(self) -> bool:
|
|
46
|
+
if self._rest > 0:
|
|
47
|
+
self._rest -= 1
|
|
48
|
+
return False
|
|
49
|
+
self.pos += 1
|
|
50
|
+
if self.pos >= self.length:
|
|
51
|
+
self.pos = -1
|
|
52
|
+
self._rest = self.rest_ticks
|
|
53
|
+
return True
|
ricekit/gallery.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""ricekit-gallery — a live demo of every piece of the kit.
|
|
2
|
+
|
|
3
|
+
Also serves as the kit's integration test: if the gallery runs, the
|
|
4
|
+
themes, palette, widgets, and modals all compose.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual import on
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical
|
|
16
|
+
from textual.widgets import Footer, OptionList, Static
|
|
17
|
+
from textual.widgets.option_list import Option
|
|
18
|
+
|
|
19
|
+
from ricekit import __version__, icons
|
|
20
|
+
from ricekit.app import KitApp
|
|
21
|
+
from ricekit.modals import HelpModal, PickerModal
|
|
22
|
+
from ricekit.palette import palette
|
|
23
|
+
from ricekit.storage import AppDirs
|
|
24
|
+
from ricekit.widgets import KitScroll, NavList, Splitter, pop_in
|
|
25
|
+
|
|
26
|
+
DIRS = AppDirs("ricekit-gallery")
|
|
27
|
+
|
|
28
|
+
HELP_SECTIONS = [
|
|
29
|
+
("navigate", [("j / k / arrows", "move"), ("enter", "show a demo"), ("esc", "close modal")]),
|
|
30
|
+
("kit", [("t", "cycle the five kit themes"), ("ctrl+p", "palette → Change theme (live preview)"),
|
|
31
|
+
("p", "open a PickerModal"), ("?", "this help")]),
|
|
32
|
+
("layout", [("drag the divider", "resize the sidebar"), ("double-click it", "reset")]),
|
|
33
|
+
("app", [("q", "quit")]),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
DEMOS = ["palette", "icons", "state glyphs", "bars", "widgets", "philosophy"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Gallery(KitApp):
|
|
40
|
+
TITLE = "ricekit gallery"
|
|
41
|
+
|
|
42
|
+
BINDINGS = [
|
|
43
|
+
Binding("t", "cycle_kit_theme", "theme"),
|
|
44
|
+
Binding("p", "picker_demo", "picker"),
|
|
45
|
+
Binding("question_mark", "help", "help"),
|
|
46
|
+
Binding("q", "quit", "quit"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
CSS = """
|
|
50
|
+
#side {
|
|
51
|
+
width: 26;
|
|
52
|
+
margin: 0 0 0 1;
|
|
53
|
+
border: round $kit-border;
|
|
54
|
+
border-title-color: $kit-border-alt;
|
|
55
|
+
}
|
|
56
|
+
#side:focus-within { border: round $kit-border-focus; }
|
|
57
|
+
#stage {
|
|
58
|
+
width: 1fr;
|
|
59
|
+
margin: 0 1 0 0;
|
|
60
|
+
border: round $kit-border;
|
|
61
|
+
border-title-color: $kit-border-alt;
|
|
62
|
+
}
|
|
63
|
+
#stage-body { height: auto; padding: 1 2; }
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
with Horizontal():
|
|
68
|
+
with Vertical(id="side"):
|
|
69
|
+
yield NavList(id="demos")
|
|
70
|
+
yield Splitter("#side", min_width=18, max_width=40,
|
|
71
|
+
on_resized=self._layout_changed, id="split")
|
|
72
|
+
with Vertical(id="stage"):
|
|
73
|
+
with KitScroll(id="stage-scroll"):
|
|
74
|
+
yield Static(id="stage-body")
|
|
75
|
+
yield Footer()
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
state = DIRS.load_state()
|
|
79
|
+
self.init_kit(theme=state.get("theme"))
|
|
80
|
+
side = self.query_one("#side")
|
|
81
|
+
side.border_title = f" {icons.STAR} ricekit {__version__} "
|
|
82
|
+
if w := state.get("side_w"):
|
|
83
|
+
side.styles.width = int(w)
|
|
84
|
+
self.query_one("#stage").border_title = " gallery "
|
|
85
|
+
demos = self.query_one("#demos", NavList)
|
|
86
|
+
demos.add_options([Option(Text(f" {d}"), id=d) for d in DEMOS])
|
|
87
|
+
demos.highlighted = 0
|
|
88
|
+
demos.focus()
|
|
89
|
+
self._show("palette")
|
|
90
|
+
|
|
91
|
+
def _layout_changed(self, target: str, width: int | None) -> None:
|
|
92
|
+
DIRS.save_state({"side_w": width})
|
|
93
|
+
|
|
94
|
+
def on_kit_theme_changed(self) -> None:
|
|
95
|
+
# re-render palette-dependent content; persist unless previewing
|
|
96
|
+
current = self.query_one("#demos", NavList).highlighted or 0
|
|
97
|
+
self._show(DEMOS[current])
|
|
98
|
+
if not self.kit_theme_previewing:
|
|
99
|
+
DIRS.save_state({"theme": self.theme})
|
|
100
|
+
|
|
101
|
+
# ── demo panes ────────────────────────────────────────────────────
|
|
102
|
+
@on(OptionList.OptionHighlighted, "#demos")
|
|
103
|
+
def _pick(self, event: OptionList.OptionHighlighted) -> None:
|
|
104
|
+
if event.option.id:
|
|
105
|
+
self._show(event.option.id)
|
|
106
|
+
|
|
107
|
+
def _show(self, demo: str) -> None:
|
|
108
|
+
body = Text()
|
|
109
|
+
if demo == "palette":
|
|
110
|
+
body.append("the swappable chrome palette\n\n", style=f"bold {palette.text}")
|
|
111
|
+
for role in ("text", "sub", "dim", "faint", "vfaint", "blue", "lav",
|
|
112
|
+
"peach", "green", "red", "mauve", "yellow"):
|
|
113
|
+
value = getattr(palette, role)
|
|
114
|
+
body.append(" ██ ", style=value)
|
|
115
|
+
body.append(f"palette.{role}".ljust(18), style=palette.sub)
|
|
116
|
+
body.append(f"{value}\n", style=palette.dim)
|
|
117
|
+
body.append("\nswitch to the system theme (t) and watch these\n"
|
|
118
|
+
"become your terminal's own ANSI colors.", style=palette.dim)
|
|
119
|
+
elif demo == "icons":
|
|
120
|
+
body.append("nerd-font icons (as \\uXXXX escapes)\n\n", style=f"bold {palette.text}")
|
|
121
|
+
names = [n for n in dir(icons) if n.isupper() and isinstance(getattr(icons, n), str)
|
|
122
|
+
and n not in ("BULLET", "DOT_SEP")]
|
|
123
|
+
for name in sorted(names):
|
|
124
|
+
body.append(f" {getattr(icons, name)} ", style=palette.blue)
|
|
125
|
+
body.append(f"icons.{name}\n", style=palette.sub)
|
|
126
|
+
elif demo == "state glyphs":
|
|
127
|
+
body.append("workflow-state circles (plain unicode)\n\n", style=f"bold {palette.text}")
|
|
128
|
+
colors = {"triage": palette.mauve, "backlog": palette.dim,
|
|
129
|
+
"unstarted": palette.sub, "started": palette.yellow,
|
|
130
|
+
"review": palette.green, "completed": palette.blue,
|
|
131
|
+
"canceled": palette.dim}
|
|
132
|
+
for state, glyph in icons.STATE_GLYPHS.items():
|
|
133
|
+
body.append(f" {glyph} ", style=colors.get(state, palette.sub))
|
|
134
|
+
body.append(f"{state}\n", style=palette.sub)
|
|
135
|
+
elif demo == "bars":
|
|
136
|
+
body.append("mini bar gauges\n\n", style=f"bold {palette.text}")
|
|
137
|
+
for label, lit in (("urgent", 3), ("high", 3), ("medium", 2), ("low", 1), ("none", 0)):
|
|
138
|
+
body.append(" ")
|
|
139
|
+
body.append_text(icons.bars(lit, palette.sub, palette.vfaint))
|
|
140
|
+
body.append(f" {label}\n", style=palette.sub)
|
|
141
|
+
elif demo == "widgets":
|
|
142
|
+
body.append("what you're looking at\n\n", style=f"bold {palette.text}")
|
|
143
|
+
for name, desc in (
|
|
144
|
+
("NavList", "vim keys, quiet cursor via $kit-cursor"),
|
|
145
|
+
("Splitter", "drag the divider left of this pane; double-click resets"),
|
|
146
|
+
("KitScroll", "focusable scrolling with j/k"),
|
|
147
|
+
("PickerModal", "press p"),
|
|
148
|
+
("ThemeModal", "ctrl+p → Change theme — previews as you scroll"),
|
|
149
|
+
("HelpModal", "press ?"),
|
|
150
|
+
("pop_in", "every modal fades in (~150ms)"),
|
|
151
|
+
):
|
|
152
|
+
body.append(f" {name.ljust(14)}", style=palette.blue)
|
|
153
|
+
body.append(f"{desc}\n", style=palette.sub)
|
|
154
|
+
elif demo == "philosophy":
|
|
155
|
+
body.append("the short version\n\n", style=f"bold {palette.text}")
|
|
156
|
+
for line in (
|
|
157
|
+
"cache first — render instantly, refresh in the background",
|
|
158
|
+
"rounded borders, titles in the border, one accent at a time",
|
|
159
|
+
"chrome colors are roles, data colors are data",
|
|
160
|
+
"keyboard first, everything clickable anyway",
|
|
161
|
+
"motion is a fade, 150ms, out_cubic — never more",
|
|
162
|
+
"themes restyle chrome only; clear + system respect the rice",
|
|
163
|
+
):
|
|
164
|
+
body.append(f" {icons.CHECK} ", style=palette.green)
|
|
165
|
+
body.append(f"{line}\n", style=palette.sub)
|
|
166
|
+
body.append("\nfull document: DESIGN.md", style=palette.dim)
|
|
167
|
+
self.query_one("#stage-body", Static).update(body)
|
|
168
|
+
self.query_one("#stage").border_subtitle = f" {demo} "
|
|
169
|
+
|
|
170
|
+
# ── modal demos ───────────────────────────────────────────────────
|
|
171
|
+
def action_picker_demo(self) -> None:
|
|
172
|
+
opts = []
|
|
173
|
+
for i, (glyph, label) in enumerate([
|
|
174
|
+
(icons.CHECK_CIRCLE, "ship it"),
|
|
175
|
+
(icons.CLOCK, "later"),
|
|
176
|
+
(icons.CROSS_CIRCLE, "never"),
|
|
177
|
+
]):
|
|
178
|
+
row = Text()
|
|
179
|
+
row.append(f"{glyph} ", style=(palette.green, palette.yellow, palette.red)[i])
|
|
180
|
+
row.append(label, style=palette.text)
|
|
181
|
+
opts.append(Option(row, id=label))
|
|
182
|
+
|
|
183
|
+
def done(choice: str | None) -> None:
|
|
184
|
+
if choice:
|
|
185
|
+
self.notify(f"{icons.CHECK} you picked: {choice}")
|
|
186
|
+
|
|
187
|
+
self.push_screen(PickerModal("a PickerModal", opts), done)
|
|
188
|
+
|
|
189
|
+
def action_help(self) -> None:
|
|
190
|
+
if isinstance(self.screen, HelpModal):
|
|
191
|
+
return
|
|
192
|
+
self.push_screen(HelpModal(HELP_SECTIONS, title=f"{icons.KEYBOARD} ricekit gallery"))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> None:
|
|
196
|
+
if "--version" in sys.argv or "-v" in sys.argv:
|
|
197
|
+
print(f"ricekit {__version__}")
|
|
198
|
+
return
|
|
199
|
+
Gallery().run()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
ricekit/icons.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Curated nerd-font icons, written as \\uXXXX escapes so source stays ASCII-safe.
|
|
2
|
+
|
|
3
|
+
Never string-match raw private-use-area glyphs in tooling: PUA bytes do not
|
|
4
|
+
round-trip reliably through editors and patch scripts. Escapes always do.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# people & actions
|
|
13
|
+
USER = "\uf007"
|
|
14
|
+
USERS = "\uf0c0"
|
|
15
|
+
CHECK = "\uf00c"
|
|
16
|
+
CHECK_CIRCLE = "\uf058"
|
|
17
|
+
CROSS_CIRCLE = "\uf057"
|
|
18
|
+
PLUS = "\uf067"
|
|
19
|
+
PENCIL = "\uf040"
|
|
20
|
+
TRASH = "\uf1f8"
|
|
21
|
+
|
|
22
|
+
# state & alerts
|
|
23
|
+
WARN = "\uf071"
|
|
24
|
+
BAN = "\uf05e"
|
|
25
|
+
MINUS_CIRCLE = "\uf056" # blocked
|
|
26
|
+
EXCLAIM_CIRCLE = "\uf06a" # blocking
|
|
27
|
+
INFO_CIRCLE = "\uf05a"
|
|
28
|
+
|
|
29
|
+
# objects
|
|
30
|
+
TAG = "\uf02b"
|
|
31
|
+
COMMENT = "\uf075"
|
|
32
|
+
CLOCK = "\uf017"
|
|
33
|
+
CALENDAR = "\uf073"
|
|
34
|
+
BRANCH = "\uf126"
|
|
35
|
+
LINK = "\uf0c1"
|
|
36
|
+
EXTERNAL = "\uf08e"
|
|
37
|
+
LIST = "\uf03a"
|
|
38
|
+
STAR = "\uf005"
|
|
39
|
+
KEYBOARD = "\uf11c"
|
|
40
|
+
GEAR = "\uf013"
|
|
41
|
+
PLUG = "\uf1e6"
|
|
42
|
+
PAINTBRUSH = "\uf1fc"
|
|
43
|
+
SEARCH = "\uf002"
|
|
44
|
+
FILTER = "\uf0b0"
|
|
45
|
+
REFRESH = "\uf021"
|
|
46
|
+
LEVEL_UP = "\uf148" # parent / up the tree
|
|
47
|
+
SITEMAP = "\uf0e8" # children / sub-items
|
|
48
|
+
|
|
49
|
+
# workflow-state circles (plain unicode: consistent geometry, no font needed)
|
|
50
|
+
STATE_GLYPHS = {
|
|
51
|
+
"triage": "\u25ce", # circled ring
|
|
52
|
+
"backlog": "\u25cc", # dotted circle
|
|
53
|
+
"unstarted": "\u25cb", # empty circle
|
|
54
|
+
"started": "\u25d0", # half circle
|
|
55
|
+
"review": "\u25d1", # other half
|
|
56
|
+
"completed": "\u25cf", # full circle
|
|
57
|
+
"canceled": "\u2298", # slashed circle
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
BULLET = "\u25cf"
|
|
61
|
+
DOT_SEP = " \u00b7 "
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def bars(lit: int, on: str, off: str, total: int = 3) -> Text:
|
|
65
|
+
"""Mini bar gauge — e.g. priority levels, capacity, progress."""
|
|
66
|
+
chars = "\u2582\u2584\u2586\u2587\u2588"[:total]
|
|
67
|
+
t = Text()
|
|
68
|
+
for i, ch in enumerate(chars):
|
|
69
|
+
t.append(ch, style=on if i < lit else off)
|
|
70
|
+
return t
|
ricekit/modals.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Modal building blocks: picker, theme picker with live preview, help sheet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import OptionList, Static
|
|
12
|
+
from textual.widgets.option_list import Option
|
|
13
|
+
|
|
14
|
+
from ricekit.palette import palette
|
|
15
|
+
from ricekit.widgets import NavList, pop_in
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PickerModal(ModalScreen):
|
|
19
|
+
"""Generic list picker: dismisses with the selected option's id (or None)."""
|
|
20
|
+
|
|
21
|
+
BINDINGS = [Binding("escape", "cancel", show=False)]
|
|
22
|
+
|
|
23
|
+
DEFAULT_CSS = """
|
|
24
|
+
PickerModal { align: center middle; background: $kit-overlay; }
|
|
25
|
+
PickerModal #kit-picker-box {
|
|
26
|
+
width: 44; height: auto; max-height: 80%;
|
|
27
|
+
background: $kit-modal-bg; border: round $kit-border-focus; padding: 1 1;
|
|
28
|
+
}
|
|
29
|
+
PickerModal #kit-picker-title { padding: 0 1 1 1; text-style: bold; }
|
|
30
|
+
PickerModal #kit-picker-list { height: auto; max-height: 16; }
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, title: str, options: list[Option]) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._title = title
|
|
36
|
+
self._options = options
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Vertical(id="kit-picker-box"):
|
|
40
|
+
yield Static(Text(self._title, style=f"bold {palette.sub}"), id="kit-picker-title")
|
|
41
|
+
yield NavList(*self._options, id="kit-picker-list")
|
|
42
|
+
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
pop_in(self.query_one("#kit-picker-box"))
|
|
45
|
+
self.query_one("#kit-picker-list").focus()
|
|
46
|
+
|
|
47
|
+
@on(OptionList.OptionSelected)
|
|
48
|
+
def _selected(self, event: OptionList.OptionSelected) -> None:
|
|
49
|
+
self.dismiss(event.option.id)
|
|
50
|
+
|
|
51
|
+
def action_cancel(self) -> None:
|
|
52
|
+
self.dismiss(None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ThemeModal(ModalScreen):
|
|
56
|
+
"""Theme picker — highlighting a theme previews it live across the app.
|
|
57
|
+
|
|
58
|
+
While this modal is on screen, `KitApp.kit_theme_previewing` is True;
|
|
59
|
+
persistence hooks should check it (commit fires one final change with
|
|
60
|
+
the flag off).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
BINDINGS = [Binding("escape", "cancel", show=False)]
|
|
64
|
+
|
|
65
|
+
DEFAULT_CSS = """
|
|
66
|
+
ThemeModal { align: center middle; background: $kit-overlay; }
|
|
67
|
+
ThemeModal #kit-theme-box {
|
|
68
|
+
width: 40; height: auto; max-height: 85%;
|
|
69
|
+
background: $kit-modal-bg; border: round $kit-border-focus; padding: 1 1;
|
|
70
|
+
}
|
|
71
|
+
ThemeModal #kit-theme-title { padding: 0 1 1 1; text-style: bold; }
|
|
72
|
+
ThemeModal #kit-theme-list { height: auto; max-height: 18; }
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def compose(self) -> ComposeResult:
|
|
76
|
+
with Vertical(id="kit-theme-box"):
|
|
77
|
+
yield Static(
|
|
78
|
+
Text("theme · scroll to preview", style=f"bold {palette.sub}"),
|
|
79
|
+
id="kit-theme-title",
|
|
80
|
+
)
|
|
81
|
+
yield NavList(id="kit-theme-list")
|
|
82
|
+
|
|
83
|
+
def _row(self, name: str, active: str) -> Option:
|
|
84
|
+
row = Text(" ")
|
|
85
|
+
on_it = name == active
|
|
86
|
+
row.append("● " if on_it else "○ ", style=palette.blue if on_it else palette.dim)
|
|
87
|
+
row.append(name, style=palette.text if on_it else palette.sub)
|
|
88
|
+
return Option(row, id=name)
|
|
89
|
+
|
|
90
|
+
def on_mount(self) -> None:
|
|
91
|
+
from ricekit.themes import KIT_THEME_NAMES
|
|
92
|
+
|
|
93
|
+
pop_in(self.query_one("#kit-theme-box"))
|
|
94
|
+
app = self.app
|
|
95
|
+
setattr(app, "kit_theme_previewing", True)
|
|
96
|
+
self._original = app.theme
|
|
97
|
+
ol = self.query_one("#kit-theme-list", NavList)
|
|
98
|
+
opts = [Option(Text(" kit", style=f"bold {palette.sub}"), disabled=True)]
|
|
99
|
+
index_of: dict[str, int] = {}
|
|
100
|
+
for name in KIT_THEME_NAMES:
|
|
101
|
+
index_of[name] = len(opts)
|
|
102
|
+
opts.append(self._row(name, self._original))
|
|
103
|
+
extra = sorted(n for n in app.available_themes if n not in KIT_THEME_NAMES)
|
|
104
|
+
if extra:
|
|
105
|
+
opts.append(Option(Text(" "), disabled=True))
|
|
106
|
+
opts.append(Option(Text(" textual", style=f"bold {palette.sub}"), disabled=True))
|
|
107
|
+
for name in extra:
|
|
108
|
+
index_of[name] = len(opts)
|
|
109
|
+
opts.append(self._row(name, self._original))
|
|
110
|
+
ol.add_options(opts)
|
|
111
|
+
ol.highlighted = index_of.get(self._original, 1)
|
|
112
|
+
ol.focus()
|
|
113
|
+
|
|
114
|
+
@on(OptionList.OptionHighlighted, "#kit-theme-list")
|
|
115
|
+
def _preview(self, event: OptionList.OptionHighlighted) -> None:
|
|
116
|
+
if event.option.id:
|
|
117
|
+
self.app.theme = event.option.id
|
|
118
|
+
|
|
119
|
+
@on(OptionList.OptionSelected, "#kit-theme-list")
|
|
120
|
+
def _select(self, event: OptionList.OptionSelected) -> None:
|
|
121
|
+
setattr(self.app, "kit_theme_previewing", False)
|
|
122
|
+
if event.option.id:
|
|
123
|
+
self.app.theme = event.option.id
|
|
124
|
+
# re-fire so persistence hooks see the final (non-preview) change
|
|
125
|
+
self.app.theme_changed_signal.publish(self.app.current_theme)
|
|
126
|
+
self.dismiss(True)
|
|
127
|
+
|
|
128
|
+
def action_cancel(self) -> None:
|
|
129
|
+
setattr(self.app, "kit_theme_previewing", False)
|
|
130
|
+
self.app.theme = self._original
|
|
131
|
+
self.dismiss(False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HelpModal(ModalScreen):
|
|
135
|
+
"""Keybinding cheatsheet. `sections` is [(title, [(key, description), …]), …]."""
|
|
136
|
+
|
|
137
|
+
BINDINGS = [
|
|
138
|
+
Binding("escape", "close_modal", show=False),
|
|
139
|
+
Binding("question_mark", "close_modal", show=False),
|
|
140
|
+
Binding("q", "close_modal", show=False),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
DEFAULT_CSS = """
|
|
144
|
+
HelpModal { align: center middle; background: $kit-overlay; }
|
|
145
|
+
HelpModal #kit-help-box {
|
|
146
|
+
width: 64; height: auto; max-height: 90%;
|
|
147
|
+
background: $kit-modal-bg; border: round $kit-border-focus; padding: 1 2;
|
|
148
|
+
}
|
|
149
|
+
HelpModal #kit-help-body { height: auto; max-height: 30; scrollbar-size-vertical: 1; }
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, sections: list[tuple[str, list[tuple[str, str]]]], title: str = "keys") -> None:
|
|
153
|
+
super().__init__()
|
|
154
|
+
self._sections = sections
|
|
155
|
+
self._title = title
|
|
156
|
+
|
|
157
|
+
def compose(self) -> ComposeResult:
|
|
158
|
+
from ricekit.widgets import KitScroll
|
|
159
|
+
|
|
160
|
+
with Vertical(id="kit-help-box"):
|
|
161
|
+
yield Static(Text(self._title, style=f"bold {palette.sub}"))
|
|
162
|
+
with KitScroll(id="kit-help-body"):
|
|
163
|
+
yield Static(self._render_sections(), id="kit-help-text")
|
|
164
|
+
|
|
165
|
+
def _render_sections(self) -> Text:
|
|
166
|
+
# (not `_render` — that's a real internal method on every Widget)
|
|
167
|
+
body = Text()
|
|
168
|
+
key_w = max(
|
|
169
|
+
(len(k) for _, rows in self._sections for k, _ in rows), default=8
|
|
170
|
+
)
|
|
171
|
+
for i, (section, rows) in enumerate(self._sections):
|
|
172
|
+
if i:
|
|
173
|
+
body.append("\n")
|
|
174
|
+
body.append(f"\n {section}\n", style=f"bold {palette.dim}")
|
|
175
|
+
for key, desc in rows:
|
|
176
|
+
body.append(f" {key.ljust(key_w + 2)}", style=palette.blue)
|
|
177
|
+
body.append(f"{desc}\n", style=palette.sub)
|
|
178
|
+
return body
|
|
179
|
+
|
|
180
|
+
def on_mount(self) -> None:
|
|
181
|
+
pop_in(self.query_one("#kit-help-box"))
|
|
182
|
+
|
|
183
|
+
def action_close_modal(self) -> None:
|
|
184
|
+
self.dismiss(None)
|
ricekit/palette.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The chrome palette — every UI color that isn't data.
|
|
2
|
+
|
|
3
|
+
Reference `palette.<role>` at render time (never bake into import-time
|
|
4
|
+
constants) so switching to the ANSI palette restyles a live app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
TRUECOLOR = {
|
|
10
|
+
"text": "#cdd6f4",
|
|
11
|
+
"sub": "#a6adc8",
|
|
12
|
+
"dim": "#6c7086",
|
|
13
|
+
"faint": "#45475a",
|
|
14
|
+
"vfaint": "#313244",
|
|
15
|
+
"blue": "#89b4fa",
|
|
16
|
+
"lav": "#b4befe",
|
|
17
|
+
"peach": "#fab387",
|
|
18
|
+
"green": "#a6e3a1",
|
|
19
|
+
"red": "#f38ba8",
|
|
20
|
+
"mauve": "#cba6f7",
|
|
21
|
+
"yellow": "#f9e2af",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# terminal-native equivalents, used by the `system` theme
|
|
25
|
+
ANSI = {
|
|
26
|
+
"text": "default",
|
|
27
|
+
"sub": "white",
|
|
28
|
+
"dim": "bright_black",
|
|
29
|
+
"faint": "bright_black",
|
|
30
|
+
"vfaint": "bright_black",
|
|
31
|
+
"blue": "blue",
|
|
32
|
+
"lav": "bright_blue",
|
|
33
|
+
"peach": "yellow",
|
|
34
|
+
"green": "green",
|
|
35
|
+
"red": "red",
|
|
36
|
+
"mauve": "magenta",
|
|
37
|
+
"yellow": "yellow",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Palette:
|
|
42
|
+
"""Swappable color roles for text chrome (rich styles, not CSS)."""
|
|
43
|
+
|
|
44
|
+
text: str
|
|
45
|
+
sub: str
|
|
46
|
+
dim: str
|
|
47
|
+
faint: str
|
|
48
|
+
vfaint: str
|
|
49
|
+
blue: str
|
|
50
|
+
lav: str
|
|
51
|
+
peach: str
|
|
52
|
+
green: str
|
|
53
|
+
red: str
|
|
54
|
+
mauve: str
|
|
55
|
+
yellow: str
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.set_ansi(False)
|
|
59
|
+
|
|
60
|
+
def set_ansi(self, ansi: bool) -> None:
|
|
61
|
+
self.__dict__.update(ANSI if ansi else TRUECOLOR)
|
|
62
|
+
self.is_ansi = ansi
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
palette = Palette()
|
|
66
|
+
"""The shared palette instance. `from ricekit import palette`."""
|
ricekit/storage.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""XDG-flavoured state, cache, and config for an app — the cache-first doctrine.
|
|
2
|
+
|
|
3
|
+
Render from cache instantly, refresh in the background, and make every
|
|
4
|
+
mutation update the cache immediately so what the user sees is always what
|
|
5
|
+
they did.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AppDirs:
|
|
16
|
+
"""Per-app file locations plus tiny JSON state/cache helpers.
|
|
17
|
+
|
|
18
|
+
`save_state` MERGES a patch over what's on disk — callers never clobber
|
|
19
|
+
keys owned by other features (a lesson learned the hard way).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, name: str) -> None:
|
|
23
|
+
self.name = name
|
|
24
|
+
self.state_file = Path.home() / ".local/state" / name / "state.json"
|
|
25
|
+
self.cache_dir = Path.home() / ".cache" / name
|
|
26
|
+
self.config_file = Path.home() / ".config" / name / "config.toml"
|
|
27
|
+
|
|
28
|
+
# ── state ─────────────────────────────────────────────────────────
|
|
29
|
+
def load_state(self) -> dict:
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(self.state_file.read_text())
|
|
32
|
+
except Exception:
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
def save_state(self, patch: dict) -> None:
|
|
36
|
+
try:
|
|
37
|
+
data = self.load_state()
|
|
38
|
+
data.update(patch)
|
|
39
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.state_file.write_text(json.dumps(data))
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# ── cache ─────────────────────────────────────────────────────────
|
|
45
|
+
def read_cache(self, name: str) -> dict | None:
|
|
46
|
+
try:
|
|
47
|
+
return json.loads((self.cache_dir / f"{name}.json").read_text())
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def write_cache(self, name: str, data: dict) -> None:
|
|
52
|
+
try:
|
|
53
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
(self.cache_dir / f"{name}.json").write_text(json.dumps(data))
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def clear_cache(self) -> int:
|
|
59
|
+
count = 0
|
|
60
|
+
try:
|
|
61
|
+
for f in self.cache_dir.glob("*.json"):
|
|
62
|
+
f.unlink()
|
|
63
|
+
count += 1
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return count
|
|
67
|
+
|
|
68
|
+
# ── config ────────────────────────────────────────────────────────
|
|
69
|
+
def load_config(self) -> dict:
|
|
70
|
+
try:
|
|
71
|
+
return tomllib.loads(self.config_file.read_text())
|
|
72
|
+
except Exception:
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
def save_secret(self, key: str, value: str) -> None:
|
|
76
|
+
"""Write a single-value secret config (chmod 600)."""
|
|
77
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
self.config_file.write_text(f'{key} = "{value}"\n')
|
|
79
|
+
self.config_file.chmod(0o600)
|
ricekit/themes.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The five kit themes and their CSS variable contract.
|
|
2
|
+
|
|
3
|
+
Every theme defines the same `kit-*` variables, plus explicit scrollbar and
|
|
4
|
+
text-selection colors (Textual derives both from `primary` otherwise, which
|
|
5
|
+
makes them identical murky blue). App stylesheets should use:
|
|
6
|
+
|
|
7
|
+
border: round $kit-border; /* resting chrome */
|
|
8
|
+
border: round $kit-border-focus; /* focused panel */
|
|
9
|
+
border: round $kit-border-alt; /* secondary emphasis */
|
|
10
|
+
background: $kit-modal-bg; /* modal boxes */
|
|
11
|
+
background: $kit-cursor; /* list-cursor rows */
|
|
12
|
+
background: $kit-overlay; /* modal screen dim layer */
|
|
13
|
+
|
|
14
|
+
`clear` and `system` use ansi_default backgrounds: pair them with
|
|
15
|
+
`KitApp`, which flips `App.ansi_color` so the terminal's own background
|
|
16
|
+
(and palette, for `system`) shows through.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from textual.theme import Theme
|
|
22
|
+
|
|
23
|
+
_ACCENTS = dict(success="#a6e3a1", warning="#f9e2af", error="#f38ba8", dark=True)
|
|
24
|
+
|
|
25
|
+
KIT_THEMES: list[Theme] = [
|
|
26
|
+
Theme(
|
|
27
|
+
name="mocha",
|
|
28
|
+
primary="#89b4fa", secondary="#cba6f7", accent="#f5c2e7",
|
|
29
|
+
background="#1e1e2e", surface="#313244", panel="#181825",
|
|
30
|
+
foreground="#cdd6f4", **_ACCENTS,
|
|
31
|
+
variables={
|
|
32
|
+
"kit-border": "#45475a",
|
|
33
|
+
"kit-border-focus": "#89b4fa",
|
|
34
|
+
"kit-border-alt": "#b4befe",
|
|
35
|
+
"kit-modal-bg": "#181825",
|
|
36
|
+
"kit-cursor": "#3e4869",
|
|
37
|
+
"kit-overlay": "black 40%",
|
|
38
|
+
"scrollbar": "#313244",
|
|
39
|
+
"scrollbar-hover": "#45475a",
|
|
40
|
+
"scrollbar-active": "#585b70",
|
|
41
|
+
"scrollbar-background": "#181825",
|
|
42
|
+
"screen-selection-background": "#b4befe 35%",
|
|
43
|
+
"input-selection-background": "#b4befe 35%",
|
|
44
|
+
},
|
|
45
|
+
),
|
|
46
|
+
Theme(
|
|
47
|
+
name="void",
|
|
48
|
+
primary="#89b4fa", secondary="#cba6f7", accent="#f5c2e7",
|
|
49
|
+
background="#000000", surface="#101018", panel="#070709",
|
|
50
|
+
foreground="#cdd6f4", **_ACCENTS,
|
|
51
|
+
variables={
|
|
52
|
+
"kit-border": "#26262e",
|
|
53
|
+
"kit-border-focus": "#89b4fa",
|
|
54
|
+
"kit-border-alt": "#b4befe",
|
|
55
|
+
"kit-modal-bg": "#0a0a10",
|
|
56
|
+
"kit-cursor": "#1e2a4a",
|
|
57
|
+
"kit-overlay": "black 40%",
|
|
58
|
+
"scrollbar": "#1e1e28",
|
|
59
|
+
"scrollbar-hover": "#2c2c38",
|
|
60
|
+
"scrollbar-active": "#3c3c4a",
|
|
61
|
+
"scrollbar-background": "#0a0a10",
|
|
62
|
+
"screen-selection-background": "#b4befe 30%",
|
|
63
|
+
"input-selection-background": "#b4befe 30%",
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
Theme(
|
|
67
|
+
name="onyx",
|
|
68
|
+
primary="#9aa5b5", secondary="#7d8494", accent="#b8c0cc",
|
|
69
|
+
background="#0e0e11", surface="#1b1b20", panel="#131317",
|
|
70
|
+
foreground="#d4d6dd", **_ACCENTS,
|
|
71
|
+
variables={
|
|
72
|
+
"kit-border": "#33333c",
|
|
73
|
+
"kit-border-focus": "#9aa5b5",
|
|
74
|
+
"kit-border-alt": "#b8c0cc",
|
|
75
|
+
"kit-modal-bg": "#141419",
|
|
76
|
+
"kit-cursor": "#2b303b",
|
|
77
|
+
"kit-overlay": "black 40%",
|
|
78
|
+
"scrollbar": "#2a2a32",
|
|
79
|
+
"scrollbar-hover": "#3a3a44",
|
|
80
|
+
"scrollbar-active": "#4a4a56",
|
|
81
|
+
"scrollbar-background": "#131317",
|
|
82
|
+
"screen-selection-background": "#b8c0cc 30%",
|
|
83
|
+
"input-selection-background": "#b8c0cc 30%",
|
|
84
|
+
},
|
|
85
|
+
),
|
|
86
|
+
# no background at all — the terminal's own background (and any
|
|
87
|
+
# blur/transparency it has) shows through; muted grey-slate chrome
|
|
88
|
+
Theme(
|
|
89
|
+
name="clear",
|
|
90
|
+
primary="#8a93a5", secondary="#6f7787", accent="#a9b1c0",
|
|
91
|
+
background="ansi_default", surface="ansi_default", panel="ansi_default",
|
|
92
|
+
foreground="#cdd6f4", **_ACCENTS,
|
|
93
|
+
variables={
|
|
94
|
+
"kit-border": "#3c3f4a",
|
|
95
|
+
"kit-border-focus": "#8a93a5",
|
|
96
|
+
"kit-border-alt": "#a9b1c0",
|
|
97
|
+
"kit-modal-bg": "#16161d",
|
|
98
|
+
"kit-cursor": "#282c38",
|
|
99
|
+
"kit-overlay": "transparent",
|
|
100
|
+
"scrollbar": "#3c3f4a",
|
|
101
|
+
"scrollbar-hover": "#4a4e5a",
|
|
102
|
+
"scrollbar-active": "#5a5f6d",
|
|
103
|
+
"scrollbar-background": "transparent",
|
|
104
|
+
"screen-selection-background": "#3f4655",
|
|
105
|
+
"input-selection-background": "#3f4655",
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
# the terminal's own ANSI palette + no background: a custom kitty /
|
|
109
|
+
# alacritty / ghostty theme becomes the app theme
|
|
110
|
+
Theme(
|
|
111
|
+
name="system",
|
|
112
|
+
primary="ansi_blue", secondary="ansi_magenta", accent="ansi_cyan",
|
|
113
|
+
background="ansi_default", surface="ansi_default", panel="ansi_default",
|
|
114
|
+
foreground="ansi_default",
|
|
115
|
+
success="ansi_green", warning="ansi_yellow", error="ansi_red", dark=True,
|
|
116
|
+
variables={
|
|
117
|
+
"kit-border": "ansi_bright_black",
|
|
118
|
+
"kit-border-focus": "ansi_blue",
|
|
119
|
+
"kit-border-alt": "ansi_bright_blue",
|
|
120
|
+
"kit-modal-bg": "ansi_black",
|
|
121
|
+
"kit-cursor": "ansi_bright_black",
|
|
122
|
+
"kit-overlay": "transparent",
|
|
123
|
+
"scrollbar": "ansi_bright_black",
|
|
124
|
+
"scrollbar-hover": "ansi_bright_black",
|
|
125
|
+
"scrollbar-active": "ansi_blue",
|
|
126
|
+
"scrollbar-background": "ansi_default",
|
|
127
|
+
"screen-selection-background": "ansi_cyan",
|
|
128
|
+
"screen-selection-foreground": "ansi_black",
|
|
129
|
+
"input-selection-background": "ansi_cyan",
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
KIT_THEME_NAMES = [t.name for t in KIT_THEMES]
|
ricekit/widgets.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ricekit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A developer's TUI suite for Textual — themes, widgets, modals, icons, and the design system behind ltui, jtui, and sctui
|
|
5
|
+
Author: Gheat
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Gheat1/ricekit
|
|
8
|
+
Project-URL: Repository, https://github.com/Gheat1/ricekit
|
|
9
|
+
Project-URL: Issues, https://github.com/Gheat1/ricekit/issues
|
|
10
|
+
Keywords: tui,textual,terminal,design-system,widgets,themes
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Terminals
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: textual>=1.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
# 🍚 ricekit
|
|
26
|
+
|
|
27
|
+
**A developer's TUI suite for [Textual](https://github.com/Textualize/textual).**
|
|
28
|
+
|
|
29
|
+
The themes, widgets, modals, icons, and design rules extracted from building
|
|
30
|
+
[ltui](https://github.com/runpantheon/ltui) — and now powering its whole family
|
|
31
|
+
(jtui, sctui, same repo) —
|
|
32
|
+
everything a fast, clean, rice-friendly terminal app needs, minus the app.
|
|
33
|
+
|
|
34
|
+
<img src="assets/gallery.png" alt="ricekit gallery" width="100%">
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## what's in the box
|
|
41
|
+
|
|
42
|
+
| module | contents |
|
|
43
|
+
| --- | --- |
|
|
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
|
+
| `ricekit.palette` | swappable chrome palette (`palette.text`, `.dim`, `.blue`, …) that flips to terminal ANSI colors under the `system` theme |
|
|
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) |
|
|
48
|
+
| `ricekit.modals` | `PickerModal` (generic chooser), `ThemeModal` (restyles the app live as you scroll), `HelpModal` (keybinding cheatsheet from plain data) |
|
|
49
|
+
| `ricekit.icons` | curated nerd-font icons as `\uXXXX` escapes + unicode state glyphs (◌ ○ ◐ ◑ ● ⊘) + mini bar gauges |
|
|
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 |
|
|
51
|
+
| `ricekit.storage` | `AppDirs` — XDG state/cache/config with merge-on-save state and chmod-600 secrets |
|
|
52
|
+
| [`DESIGN.md`](DESIGN.md) | the philosophy: cache-first speed, shape language, color roles, motion rules — and the appendix of Textual sharp edges that each cost a day to find |
|
|
53
|
+
|
|
54
|
+
## see it
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
uv tool install git+https://github.com/Gheat1/ricekit
|
|
58
|
+
ricekit-gallery
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The gallery demos the palette, icons, glyphs, widgets, and modals — cycle
|
|
62
|
+
themes with `t`, live-preview every Textual theme with `ctrl+p`, drag the
|
|
63
|
+
divider, press `?`.
|
|
64
|
+
|
|
65
|
+
## use it
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
uv add git+https://github.com/Gheat1/ricekit # or pip install git+…
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from textual.binding import Binding
|
|
73
|
+
from textual.widgets import Footer
|
|
74
|
+
|
|
75
|
+
from ricekit import KitApp, icons, palette
|
|
76
|
+
from ricekit.storage import AppDirs
|
|
77
|
+
from ricekit.widgets import NavList
|
|
78
|
+
|
|
79
|
+
DIRS = AppDirs("myapp")
|
|
80
|
+
|
|
81
|
+
class MyApp(KitApp):
|
|
82
|
+
BINDINGS = [Binding("t", "cycle_kit_theme", "theme")]
|
|
83
|
+
CSS = """
|
|
84
|
+
NavList { border: round $kit-border; }
|
|
85
|
+
NavList:focus { border: round $kit-border-focus; }
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def compose(self):
|
|
89
|
+
yield NavList(id="items")
|
|
90
|
+
yield Footer()
|
|
91
|
+
|
|
92
|
+
def on_mount(self):
|
|
93
|
+
self.init_kit(theme=DIRS.load_state().get("theme"))
|
|
94
|
+
|
|
95
|
+
def on_kit_theme_changed(self):
|
|
96
|
+
if not self.kit_theme_previewing:
|
|
97
|
+
DIRS.save_state({"theme": self.theme})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That's a themed, transparent-capable, live-previewing app in ~25 lines.
|
|
101
|
+
Read [`DESIGN.md`](DESIGN.md) before building anything serious — especially
|
|
102
|
+
the sharp-edges table.
|
|
103
|
+
|
|
104
|
+
## the suite
|
|
105
|
+
|
|
106
|
+
- [**the tracker TUI suite**](https://github.com/runpantheon/ltui) — ltui (Linear), jtui (Jira), sctui (Shortcut); where all of this came from, now backed by Pantheon
|
|
107
|
+
- [**NaviTui**](https://github.com/Gheat1/NaviTui) — an animated terminal player for Navidrome, cover art and all
|
|
108
|
+
- yours next?
|
|
109
|
+
|
|
110
|
+
## license
|
|
111
|
+
|
|
112
|
+
[MIT](LICENSE) — made by [@Gheat1](https://github.com/Gheat1)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ricekit/__init__.py,sha256=w98nVE7BAgP17vLTmPt7S98epTI0afxVvuwEro00z5c,1008
|
|
2
|
+
ricekit/app.py,sha256=G-KNgIwUmJFu-DwU26ObEXrXLqwpBryTHKMYtuySot8,3294
|
|
3
|
+
ricekit/fx.py,sha256=4luWwdbJhljgSCNrPHAJpVjDiBbkjBP-aAyWomXxy9k,1548
|
|
4
|
+
ricekit/gallery.py,sha256=PzoyHALqJD_X9U2-DK084qmKSqTTQ8df4Ht7K-VIDNU,8856
|
|
5
|
+
ricekit/icons.py,sha256=cbuWe7bEkHgmCzK8GFiGyA_aEuVU-uhSF6c0GjTMgiQ,1742
|
|
6
|
+
ricekit/modals.py,sha256=JU8IB1u8LX9BP8Vkjp8tLhiXBcdsZsxPnukMWVqQLt8,6975
|
|
7
|
+
ricekit/palette.py,sha256=YeT8u_88afef1YSj2jcpmZxWpdnlGyQj2tKtFAU8GDg,1431
|
|
8
|
+
ricekit/storage.py,sha256=680byZSzjK0bp2P-TBah8v8GvP8L_x5FzFzjpRRe_AE,2995
|
|
9
|
+
ricekit/themes.py,sha256=uXAmG4x5HfbF6B7EKMKLWiTWu66Q41zEF7HIav3xKmc,5452
|
|
10
|
+
ricekit/widgets.py,sha256=v9omCfHo8DWltJp2EFwRqgzW5bNsfFH9JqcO8mQjZII,4380
|
|
11
|
+
ricekit-0.2.0.dist-info/licenses/LICENSE,sha256=IDB7sG2R5p1TBTo0JAytpj4ghJmItQMZflxsdraGSIk,1062
|
|
12
|
+
ricekit-0.2.0.dist-info/METADATA,sha256=Ed0OB1ZhWGaYAFLhRIOtH3w6cpnTmyCkkFbvIA75nUA,4581
|
|
13
|
+
ricekit-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
ricekit-0.2.0.dist-info/entry_points.txt,sha256=lNtmENuPDSt_MfEZjJ2kNJjxSxXFzhqlVGAl38ymsu0,57
|
|
15
|
+
ricekit-0.2.0.dist-info/top_level.txt,sha256=9AtVU14JFZEKl9nLyrCZpw4p9hOaFN7e7FmERFZyBQo,8
|
|
16
|
+
ricekit-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gheat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ricekit
|