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