返回 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::palette::{self, UiTheme};
20 use crate::tui::glyphs;
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 /// Selected row with a caller-chosen foreground (the provider picker tints
33 /// per-field ink while keeping the shared selection background).
34 #[must_use]
35 pub fn selected_row_style_with_fg(fg: Color) -> Style {
36 Style::default()
37 .fg(fg)
38 .bg(palette::SELECTION_BG)
39 .add_modifier(Modifier::BOLD)
40 }
41
42 /// Selection background alone, for filler/spacer cells so the highlight band
43 /// runs the full width of a selected row.
44 #[must_use]
45 pub fn selected_row_bg_style() -> Style {
46 Style::default().bg(palette::SELECTION_BG)
47 }
48
49 /// Selected-but-disabled row (e.g. a locked model): the cursor position is
50 /// still visible, but muted ink on the elevated surface plus a dim modifier
51 /// says the row cannot be chosen.
52 #[must_use]
53 pub fn disabled_selected_row_style() -> Style {
54 Style::default()
55 .fg(palette::TEXT_MUTED)
56 .bg(palette::SURFACE_ELEVATED)
57 .add_modifier(Modifier::DIM)
58 }
59
60 /// Theme-preview variant: the theme picker shows each candidate theme's *own*
61 /// selection treatment, so ink and background come from the previewed theme
62 /// rather than the global tokens. `UiTheme` has no dedicated selection-ink
63 /// field, so the theme's body text reads on its selection background — the
64 /// exact pairing the picker has always rendered.
65 #[must_use]
66 pub fn theme_selected_row_style(theme: &UiTheme) -> Style {
67 Style::default()
68 .fg(theme.text_body)
69 .bg(theme.selection_bg)
70 .add_modifier(Modifier::BOLD)
71 }
72
73 /// Non-color status cue: a charter glyph, an English word, and a palette
74 /// status tone. Surfaces may render any subset, but glyph and word are always
75 /// both available so the state never depends on color alone.
76 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
77 pub struct StatusMark {
78 pub glyph: &'static str,
79 pub word: &'static str,
80 pub tone: Color,
81 }
82
83 /// The status states shared by the footer, work surface, and pickers.
84 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
85 pub enum StatusKind {
86 Ready,
87 Working,
88 Paused,
89 Done,
90 /// Chartered vocabulary entry. No surface renders a failure mark yet, so
91 /// nothing constructs it outside the exhaustiveness sweep below.
92 #[allow(dead_code)]
93 Failed,
94 Attention,
95 }
96
97 impl StatusKind {
98 /// Every kind, for exhaustive checks. Consumed by the non-color-meaning
99 /// test gate rather than by a runtime renderer.
100 #[allow(dead_code)]
101 pub const ALL: [StatusKind; 6] = [
102 StatusKind::Ready,
103 StatusKind::Working,
104 StatusKind::Paused,
105 StatusKind::Done,
106 StatusKind::Failed,
107 StatusKind::Attention,
108 ];
109 }
110
111 /// Single source pairing each status with its charter glyph, English word,
112 /// and palette tone. Words match the established footer vocabulary
113 /// (`working`, `paused`, `idle`); tones are `palette::STATUS_*` tokens, not
114 /// per-view colors. `Working` has no dedicated charter glyph yet, so it
115 /// borrows the charter's neutral dot until one is chartered.
116 #[must_use]
117 pub const fn status_mark(kind: StatusKind) -> StatusMark {
118 match kind {
119 StatusKind::Ready => StatusMark {
120 glyph: glyphs::READY,
121 word: "idle",
122 tone: palette::STATUS_SUCCESS,
123 },
124 StatusKind::Working => StatusMark {
125 glyph: glyphs::NEUTRAL,
126 word: "working",
127 tone: palette::STATUS_INFO,
128 },
129 StatusKind::Paused => StatusMark {
130 glyph: glyphs::PAUSED,
131 word: "paused",
132 tone: palette::STATUS_WARNING,
133 },
134 StatusKind::Done => StatusMark {
135 glyph: glyphs::DONE,
136 word: "done",
137 tone: palette::STATUS_SUCCESS,
138 },
139 StatusKind::Failed => StatusMark {
140 glyph: glyphs::FAILED,
141 word: "failed",
142 tone: palette::STATUS_ERROR,
143 },
144 StatusKind::Attention => StatusMark {
145 glyph: glyphs::ATTENTION,
146 word: "attention",
147 tone: palette::STATUS_WARNING,
148 },
149 }
150 }
151
152 #[cfg(test)]
153 mod tests {
154 use super::*;
155
156 #[test]
157 fn selected_row_style_is_the_canonical_trio() {
158 assert_eq!(
159 selected_row_style(),
160 Style::default()
161 .fg(palette::SELECTION_TEXT)
162 .bg(palette::SELECTION_BG)
163 .add_modifier(Modifier::BOLD)
164 );
165 }
166
167 #[test]
168 fn selected_row_variants_keep_the_shared_background() {
169 assert_eq!(
170 selected_row_bg_style(),
171 Style::default().bg(palette::SELECTION_BG)
172 );
173 assert_eq!(
174 selected_row_style_with_fg(palette::WHALE_INFO),
175 Style::default()
176 .fg(palette::WHALE_INFO)
177 .bg(palette::SELECTION_BG)
178 .add_modifier(Modifier::BOLD)
179 );
180 }
181
182 #[test]
183 fn disabled_selected_row_is_muted_ink_on_elevated_surface() {
184 assert_eq!(
185 disabled_selected_row_style(),
186 Style::default()
187 .fg(palette::TEXT_MUTED)
188 .bg(palette::SURFACE_ELEVATED)
189 .add_modifier(Modifier::DIM)
190 );
191 }
192
193 #[test]
194 fn theme_variant_uses_the_previewed_themes_own_tokens() {
195 let theme = palette::UI_THEME;
196 assert_eq!(
197 theme_selected_row_style(&theme),
198 Style::default()
199 .fg(theme.text_body)
200 .bg(theme.selection_bg)
201 .add_modifier(Modifier::BOLD)
202 );
203 }
204
205 #[test]
206 fn every_status_kind_pairs_a_glyph_with_a_word() {
207 // Non-color redundancy: neither cue may be empty, so a status never
208 // depends on color alone.
209 for kind in StatusKind::ALL {
210 let mark = status_mark(kind);
211 assert!(!mark.glyph.trim().is_empty(), "{kind:?} needs a glyph");
212 assert!(!mark.word.trim().is_empty(), "{kind:?} needs a word");
213 }
214 }
215
216 #[test]
217 fn status_tones_are_palette_status_tokens() {
218 let tokens = [
219 palette::STATUS_SUCCESS,
220 palette::STATUS_WARNING,
221 palette::STATUS_ERROR,
222 palette::STATUS_INFO,
223 ];
224 for kind in StatusKind::ALL {
225 let tone = status_mark(kind).tone;
226 assert!(
227 tokens.contains(&tone),
228 "{kind:?} tone is not a STATUS_* token"
229 );
230 }
231 assert_eq!(status_mark(StatusKind::Ready).tone, palette::STATUS_SUCCESS);
232 assert_eq!(status_mark(StatusKind::Working).tone, palette::STATUS_INFO);
233 assert_eq!(
234 status_mark(StatusKind::Paused).tone,
235 palette::STATUS_WARNING
236 );
237 assert_eq!(status_mark(StatusKind::Done).tone, palette::STATUS_SUCCESS);
238 assert_eq!(status_mark(StatusKind::Failed).tone, palette::STATUS_ERROR);
239 assert_eq!(
240 status_mark(StatusKind::Attention).tone,
241 palette::STATUS_WARNING
242 );
243 }
244 }
245
245 lines RUST