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