| 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 |