返回 CodeWhale
menu_style.rs
根目录 / crates / tui / src / tui / menu_style.rs
1 //! Shared selection-row styles and non-color status marks for menus/pickers.
2 //!
3 //! Contract:
4 //! - The selection vocabulary is single-sourced here. Every menu, picker, and
5 //! option list renders its selected row with [`selected_row_style`] (or one
6 //! of the documented variants below) instead of hand-copying the
7 //! `SELECTION_TEXT`-on-`SELECTION_BG` + bold trio.
8 //! - This module owns *styling* only. Verbs, action-hint labels, keybindings,
9 //! and localized strings stay with the views and `ActionHint`; nothing here
10 //! changes what any surface says.
11 //! - Status tones are palette tokens (`palette::STATUS_*`), never per-view
12 //! colors, and every [`StatusKind`] pairs a charter glyph (`glyphs.rs`) with
13 //! an English word so state never depends on color alone. Surfaces that
14 //! already source their tone from the live `UiTheme` (the footer) keep that
15 //! tone and consume only the glyph/word half of the mark.
16
17 use ratatui::style::{Color, Modifier, Style};
18
19 use crate::tui::glyphs;
20 use codewhale_palette::{self as palette, UiTheme};
21
22 /// Canonical selected-row treatment: selection ink on the selection
23 /// background, bolded so the active row reads even without color.
24 #[must_use]
25 pub fn selected_row_style() -> Style {
26 Style::default()
27 .fg(palette::SELECTION_TEXT)
28 .bg(palette::SELECTION_BG)
29 .add_modifier(Modifier::BOLD)
30 }
31
32 /// Hover is an elevated band with an underline, distinct from the filled,
33 /// bold keyboard selection even on terminals without color.
34 #[must_use]
35 pub fn hovered_row_style() -> Style {
36 Style::default()
37 .bg(palette::SURFACE_ELEVATED)
38 .add_modifier(Modifier::UNDERLINED)
39 }
40
41 /// Selected row with a caller-chosen foreground (the provider picker tints
42 /// per-field ink while keeping the shared selection background).
43 #[must_use]
44 pub fn selected_row_style_with_fg(fg: Color) -> Style {
45 Style::default()
46 .fg(fg)
47 .bg(palette::SELECTION_BG)
48 .add_modifier(Modifier::BOLD)
49 }
50
51 /// Selection background alone, for filler/spacer cells so the highlight band
52 /// runs the full width of a selected row.
53 #[must_use]
54 pub fn selected_row_bg_style() -> Style {
55 Style::default().bg(palette::SELECTION_BG)
56 }
57
58 /// Selected-but-disabled row (e.g. a locked model): the cursor position is
59 /// still visible, but muted ink on the elevated surface plus a dim modifier
60 /// says the row cannot be chosen.
61 #[must_use]
62 pub fn disabled_selected_row_style() -> Style {
63 Style::default()
64 .fg(palette::TEXT_MUTED)
65 .bg(palette::SURFACE_ELEVATED)
66 .add_modifier(Modifier::DIM)
67 }
68
69 /// Theme-preview variant: the theme picker shows each candidate theme's *own*
70 /// selection treatment, so ink and background come from the previewed theme
71 /// rather than the global tokens. `UiTheme` has no dedicated selection-ink
72 /// field, so the theme's body text reads on its selection background — the
73 /// exact pairing the picker has always rendered.
74 #[must_use]
75 pub fn theme_selected_row_style(theme: &UiTheme) -> Style {
76 Style::default()
77 .fg(theme.text_body)
78 .bg(theme.selection_bg)
79 .add_modifier(Modifier::BOLD)
80 }
81
82 /// Non-color status cue: a charter glyph, an English word, and a palette
83 /// status tone. Surfaces may render any subset, but glyph and word are always
84 /// both available so the state never depends on color alone.
85 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
86 pub struct StatusMark {
87 pub glyph: &'static str,
88 pub word: &'static str,
89 pub tone: Color,
90 }
91
92 /// The status states shared by the footer, work surface, and pickers.
93 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
94 pub enum StatusKind {
95 #[cfg_attr(not(test), expect(dead_code))]
96 Ready,
97 #[cfg_attr(not(test), expect(dead_code))]
98 Working,
99 #[cfg_attr(not(test), expect(dead_code))]
100 Paused,
101 Done,
102 /// Chartered vocabulary entry. No surface renders a failure mark yet, so
103 /// nothing constructs it outside the exhaustiveness sweep below.
104 #[cfg_attr(not(test), expect(dead_code))]
105 Failed,
106 Attention,
107 }
108
109 impl StatusKind {
110 /// Every kind, for exhaustive checks. Consumed by the non-color-meaning
111 /// test gate rather than by a runtime renderer.
112 #[allow(dead_code)]
113 pub const ALL: [StatusKind; 6] = [
114 StatusKind::Ready,
115 StatusKind::Working,
116 StatusKind::Paused,
117 StatusKind::Done,
118 StatusKind::Failed,
119 StatusKind::Attention,
120 ];
121 }
122
123 /// Single source pairing each status with its charter glyph, English word,
124 /// and palette tone. Words match the established footer vocabulary
125 /// (`working`, `paused`, `idle`); tones are `palette::STATUS_*` tokens, not
126 /// per-view colors. `Working` has no dedicated charter glyph yet, so it
127 /// borrows the charter's neutral dot until one is chartered.
128 #[must_use]
129 pub const fn status_mark(kind: StatusKind) -> StatusMark {
130 match kind {
131 StatusKind::Ready => StatusMark {
132 glyph: glyphs::READY,
133 word: "idle",
134 tone: palette::STATUS_SUCCESS,
135 },
136 StatusKind::Working => StatusMark {
137 glyph: glyphs::NEUTRAL,
138 word: "working",
139 tone: palette::WHALE_ACTION,
140 },
141 StatusKind::Paused => StatusMark {
142 glyph: glyphs::PAUSED,
143 word: "paused",
144 tone: palette::STATUS_WARNING,
145 },
146 StatusKind::Done => StatusMark {
147 glyph: glyphs::DONE,
148 word: "done",
149 tone: palette::STATUS_SUCCESS,
150 },
151 StatusKind::Failed => StatusMark {
152 glyph: glyphs::FAILED,
153 word: "failed",
154 tone: palette::STATUS_ERROR,
155 },
156 StatusKind::Attention => StatusMark {
157 glyph: glyphs::ATTENTION,
158 word: "attention",
159 tone: palette::STATUS_WARNING,
160 },
161 }
162 }
163
164 #[cfg(test)]
165 mod tests {
166 use super::*;
167
168 #[test]
169 fn selected_row_style_is_the_canonical_trio() {
170 assert_eq!(
171 selected_row_style(),
172 Style::default()
173 .fg(palette::SELECTION_TEXT)
174 .bg(palette::SELECTION_BG)
175 .add_modifier(Modifier::BOLD)
176 );
177 }
178
179 #[test]
180 fn selected_row_variants_keep_the_shared_background() {
181 assert_eq!(
182 selected_row_bg_style(),
183 Style::default().bg(palette::SELECTION_BG)
184 );
185 assert_eq!(
186 selected_row_style_with_fg(palette::WHALE_ACTION),
187 Style::default()
188 .fg(palette::WHALE_ACTION)
189 .bg(palette::SELECTION_BG)
190 .add_modifier(Modifier::BOLD)
191 );
192 }
193
194 #[test]
195 fn disabled_selected_row_is_muted_ink_on_elevated_surface() {
196 assert_eq!(
197 disabled_selected_row_style(),
198 Style::default()
199 .fg(palette::TEXT_MUTED)
200 .bg(palette::SURFACE_ELEVATED)
201 .add_modifier(Modifier::DIM)
202 );
203 }
204
205 #[test]
206 fn hovered_row_is_elevated_band_without_selection_ink() {
207 let hovered = hovered_row_style();
208 assert_eq!(
209 hovered,
210 Style::default()
211 .bg(palette::SURFACE_ELEVATED)
212 .add_modifier(Modifier::UNDERLINED)
213 );
214 assert_ne!(hovered, selected_row_style());
215 assert_ne!(hovered, selected_row_bg_style());
216 }
217
218 #[test]
219 fn theme_variant_uses_the_previewed_themes_own_tokens() {
220 let theme = palette::UI_THEME;
221 assert_eq!(
222 theme_selected_row_style(&theme),
223 Style::default()
224 .fg(theme.text_body)
225 .bg(theme.selection_bg)
226 .add_modifier(Modifier::BOLD)
227 );
228 }
229
230 #[test]
231 fn every_status_kind_pairs_a_glyph_with_a_word() {
232 // Non-color redundancy: neither cue may be empty, so a status never
233 // depends on color alone.
234 for kind in StatusKind::ALL {
235 let mark = status_mark(kind);
236 assert!(!mark.glyph.trim().is_empty(), "{kind:?} needs a glyph");
237 assert!(!mark.word.trim().is_empty(), "{kind:?} needs a word");
238 }
239 }
240
241 #[test]
242 fn status_tones_are_palette_status_tokens() {
243 let tokens = [
244 palette::STATUS_SUCCESS,
245 palette::STATUS_WARNING,
246 palette::STATUS_ERROR,
247 palette::WHALE_ACTION,
248 ];
249 for kind in StatusKind::ALL {
250 let tone = status_mark(kind).tone;
251 assert!(
252 tokens.contains(&tone),
253 "{kind:?} tone is not a STATUS_* token"
254 );
255 }
256 assert_eq!(status_mark(StatusKind::Ready).tone, palette::STATUS_SUCCESS);
257 assert_eq!(status_mark(StatusKind::Working).tone, palette::WHALE_ACTION);
258 assert_eq!(
259 status_mark(StatusKind::Paused).tone,
260 palette::STATUS_WARNING
261 );
262 assert_eq!(status_mark(StatusKind::Done).tone, palette::STATUS_SUCCESS);
263 assert_eq!(status_mark(StatusKind::Failed).tone, palette::STATUS_ERROR);
264 assert_eq!(
265 status_mark(StatusKind::Attention).tone,
266 palette::STATUS_WARNING
267 );
268 }
269 }
270
270 lines RUST