diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 87b2bb5..cfe53c4 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -22,7 +22,8 @@ use std::collections::BTreeMap; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use serde::Deserialize; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer}; /// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`). pub const DIR_NAME: &str = "maestro"; @@ -146,11 +147,14 @@ pub struct WebSection { /// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277). /// -/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships -/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are -/// applied **over** the defaults, so a partial override is enough. +/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI +/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert` +/// are applied **over** the defaults, so a partial override is enough. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct TuiSection { + /// Named palette preset. Defaults to the dark preset. + #[serde(default, deserialize_with = "deserialize_theme")] + pub theme: Option, /// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`. #[serde(default)] pub leader: Option, @@ -167,6 +171,33 @@ pub struct TuiSection { pub keymap_insert: BTreeMap, } +/// Palette presets supported by the TUI's `[tui].theme` setting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiTheme { + /// The terminal-native dark palette and the default. + Dark, + /// A high-contrast light palette. + Light, + /// Named ANSI colours, allowing the terminal to provide the colour set. + Ansi, +} + +fn deserialize_theme<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + raw.map(|name| match name.as_str() { + "dark" => Ok(TuiTheme::Dark), + "light" => Ok(TuiTheme::Light), + "ansi" => Ok(TuiTheme::Ansi), + _ => Err(D::Error::custom(format!( + "invalid [tui].theme {name:?}; expected \"dark\", \"light\", or \"ansi\"" + ))), + }) + .transpose() +} + /// `[spawn]` — command presets for the spawn form (#22, part of #21). /// /// Defined in shared config so every binary sees the same list: the web reads @@ -596,6 +627,30 @@ bind_addr = "127.0.0.1:9090" ); } + #[test] + fn tui_theme_parses_named_presets_and_defaults_when_absent() { + assert_eq!(TuiSection::default().theme, None); + let cfg = file_with("[tui]\ntheme = \"light\"\n"); + assert_eq!(cfg.tui.theme, Some(TuiTheme::Light)); + let cfg = file_with("[tui]\ntheme = \"ansi\"\n"); + assert_eq!(cfg.tui.theme, Some(TuiTheme::Ansi)); + } + + #[test] + fn tui_theme_rejects_unknown_names_with_the_field_name() { + let err = + toml::from_str::("[tui]\ntheme = \"solarized\"\n").unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("[tui].theme"), + "diagnostic should name the field: {message}" + ); + assert!( + message.contains("solarized"), + "diagnostic should name the invalid value: {message}" + ); + } + fn file_with(body: &str) -> MaestroConfigFile { toml::from_str(body).expect("parse") } diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 790e6bd..4b305ea 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -8,13 +8,15 @@ use std::time::Duration; -use maestro_config::TuiSection; +use maestro_config::{TuiSection, TuiTheme}; use crate::input::InputKey; use crate::keymap::{Keymap, KeymapError, Mode}; +use crate::palette::Palette; #[derive(Debug)] pub struct TuiConfig { + pub palette: Palette, pub leader: InputKey, pub chord_timeout: Duration, pub keymap: Keymap, @@ -76,6 +78,11 @@ impl TuiConfig { /// field of the loaded `MaestroConfigFile`). The shipped defaults are /// applied first, then user overrides on top. pub fn load(section: &TuiSection) -> Result { + let palette = match section.theme.unwrap_or(TuiTheme::Dark) { + TuiTheme::Dark => Palette::DARK, + TuiTheme::Light => Palette::LIGHT, + TuiTheme::Ansi => Palette::ANSI, + }; let leader_str = section.leader.as_deref().unwrap_or("ctrl-a"); let leader = InputKey::parse(leader_str) .map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?; @@ -131,6 +138,7 @@ impl TuiConfig { } Ok(TuiConfig { + palette, leader, chord_timeout, keymap, @@ -143,6 +151,28 @@ impl TuiConfig { mod tests { use super::*; use crate::action::Action; + use maestro_config::{TuiSection, TuiTheme}; + + #[test] + fn absent_theme_selects_the_dark_default() { + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + assert_eq!(cfg.palette, Palette::DARK); + } + + #[test] + fn named_themes_select_the_matching_complete_palette() { + for (theme, expected) in [ + (TuiTheme::Dark, Palette::DARK), + (TuiTheme::Light, Palette::LIGHT), + (TuiTheme::Ansi, Palette::ANSI), + ] { + let section = TuiSection { + theme: Some(theme), + ..Default::default() + }; + assert_eq!(TuiConfig::load(§ion).unwrap().palette, expected); + } + } #[test] fn defaults_cover_core_actions() { diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 7e5d0d6..97bc354 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -90,6 +90,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { Err(_) => maestro_config::MaestroConfigFile::default(), }; let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?; + palette::set_active(tui_cfg.palette); let catalog = CatalogStore::default(); let ws_url = daemon diff --git a/crates/maestro-tui/src/palette.rs b/crates/maestro-tui/src/palette.rs index cf8603e..49909e0 100644 --- a/crates/maestro-tui/src/palette.rs +++ b/crates/maestro-tui/src/palette.rs @@ -16,9 +16,8 @@ //! the selected card uses the selection surface's own state colours; the dot //! keeps carrying the state either way (docs/SIDEBAR.md). //! -//! One palette ships today, reached through [`active`]. Selecting between -//! presets from config is a separate issue; [`active`] is the seam it -//! replaces. +//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and +//! [`Palette::ANSI`] uses terminal-provided ANSI colours. //! //! Out of scope: PTY cell colours. Those belong to the program running in the //! pane and pass through `pane::translate_color` verbatim. @@ -130,40 +129,162 @@ pub struct Palette { } impl Palette { - /// The one palette that ships today. - pub const DEFAULT: Palette = Palette { + /// The dark terminal-native palette and the default preset. + pub const DARK: Palette = Palette { card: Surface { - bg: Color::Rgb(0x14, 0x16, 0x1b), - fg: Color::Rgb(0xd6, 0xdc, 0xe5), - muted: Color::Rgb(0x9a, 0xa3, 0xb2), - dim: Color::Rgb(0x6b, 0x72, 0x80), - active: Color::Rgb(0x3f, 0xb9, 0x50), - idle: Color::Rgb(0x8b, 0x94, 0x9e), - awaiting: Color::Rgb(0xd2, 0x99, 0x22), - error: Color::Rgb(0xf8, 0x51, 0x49), + bg: Color::Rgb(0x1f, 0x23, 0x2b), + fg: Color::Rgb(0xe5, 0xe7, 0xeb), + muted: Color::Rgb(0xae, 0xb7, 0xc4), + dim: Color::Rgb(0x85, 0x8f, 0x9e), + active: Color::Rgb(0x72, 0xd6, 0x87), + idle: Color::Rgb(0xae, 0xb7, 0xc4), + awaiting: Color::Rgb(0xf2, 0xc9, 0x5c), + error: Color::Rgb(0xff, 0x82, 0x7a), }, selection: Surface { - bg: Color::Rgb(0x2b, 0x3f, 0x6e), - fg: Color::Rgb(0xf0, 0xf4, 0xfa), - muted: Color::Rgb(0xc0, 0xc9, 0xda), - dim: Color::Rgb(0x93, 0xa0, 0xbc), - active: Color::Rgb(0x7e, 0xe7, 0x87), - idle: Color::Rgb(0xb6, 0xc0, 0xd4), - awaiting: Color::Rgb(0xf2, 0xcc, 0x60), - error: Color::Rgb(0xff, 0x94, 0x92), + bg: Color::Rgb(0x3d, 0x51, 0x44), + fg: Color::Rgb(0xf3, 0xf7, 0xf4), + muted: Color::Rgb(0xd0, 0xd9, 0xd3), + dim: Color::Rgb(0xa1, 0xb0, 0xa6), + active: Color::Rgb(0x9a, 0xf0, 0xa6), + idle: Color::Rgb(0xc4, 0xcf, 0xc7), + awaiting: Color::Rgb(0xf6, 0xd9, 0x78), + error: Color::Rgb(0xff, 0xa8, 0xa3), }, - chrome_bg: Color::Rgb(0x14, 0x16, 0x1b), - border_focused: Color::Rgb(0x58, 0xa6, 0xff), - border_unfocused: Color::Rgb(0x6b, 0x72, 0x80), - focus_accent: Color::Rgb(0x58, 0xa6, 0xff), - group_row: Color::Rgb(0x8f, 0xa3, 0xc8), - mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff), - mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11), - mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22), - mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11), - error_text: Color::Rgb(0xf8, 0x51, 0x49), + chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b), + border_focused: Color::Rgb(0x72, 0xd6, 0x87), + border_unfocused: Color::Rgb(0x68, 0x73, 0x84), + focus_accent: Color::Rgb(0x72, 0xd6, 0x87), + group_row: Color::Rgb(0xb6, 0xc1, 0xd3), + mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87), + mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b), + mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c), + mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b), + error_text: Color::Rgb(0xff, 0x82, 0x7a), }; + /// A high-contrast light peer to [`Self::DARK`]. + pub const LIGHT: Palette = Palette { + card: Surface { + bg: Color::Rgb(0xff, 0xff, 0xff), + fg: Color::Rgb(0x27, 0x31, 0x3d), + muted: Color::Rgb(0x4c, 0x5b, 0x6b), + dim: Color::Rgb(0x68, 0x78, 0x8a), + active: Color::Rgb(0x21, 0x7a, 0x3c), + idle: Color::Rgb(0x4c, 0x5b, 0x6b), + awaiting: Color::Rgb(0x8f, 0x63, 0x00), + error: Color::Rgb(0xb4, 0x23, 0x18), + }, + selection: Surface { + bg: Color::Rgb(0xb5, 0xcf, 0xba), + fg: Color::Rgb(0x1d, 0x2a, 0x24), + muted: Color::Rgb(0x3f, 0x55, 0x45), + dim: Color::Rgb(0x52, 0x68, 0x5a), + active: Color::Rgb(0x15, 0x5b, 0x2e), + idle: Color::Rgb(0x3f, 0x55, 0x45), + awaiting: Color::Rgb(0x69, 0x46, 0x00), + error: Color::Rgb(0xa5, 0x1d, 0x14), + }, + chrome_bg: Color::Rgb(0xff, 0xff, 0xff), + border_focused: Color::Rgb(0x21, 0x7a, 0x3c), + border_unfocused: Color::Rgb(0x65, 0x73, 0x6b), + focus_accent: Color::Rgb(0x21, 0x7a, 0x3c), + group_row: Color::Rgb(0x3f, 0x55, 0x45), + mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c), + mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff), + mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08), + mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e), + error_text: Color::Rgb(0xb4, 0x23, 0x18), + }; + + /// A palette of named ANSI colours for terminals without truecolour. + pub const ANSI: Palette = Palette { + card: Surface { + bg: Color::Black, + fg: Color::White, + muted: Color::Gray, + dim: Color::DarkGray, + active: Color::Green, + idle: Color::Gray, + awaiting: Color::Yellow, + error: Color::Red, + }, + selection: Surface { + bg: Color::Blue, + fg: Color::White, + muted: Color::White, + dim: Color::LightBlue, + active: Color::LightGreen, + idle: Color::White, + awaiting: Color::LightYellow, + error: Color::LightRed, + }, + chrome_bg: Color::Black, + border_focused: Color::LightGreen, + border_unfocused: Color::DarkGray, + focus_accent: Color::LightGreen, + group_row: Color::LightCyan, + mode_normal_bg: Color::Green, + mode_normal_fg: Color::Black, + mode_insert_bg: Color::Yellow, + mode_insert_fg: Color::Black, + error_text: Color::LightRed, + }; + + /// The default preset, retained as a named compatibility constant. + pub const DEFAULT: Palette = Self::DARK; + + /// All preset names and their complete palettes. + pub fn presets() -> [(&'static str, Palette); 3] { + [ + ("dark", Self::DARK), + ("light", Self::LIGHT), + ("ansi", Self::ANSI), + ] + } + + /// Resolve a config name to a palette. + pub fn named(name: &str) -> Option { + match name { + "dark" => Some(Self::DARK), + "light" => Some(Self::LIGHT), + "ansi" => Some(Self::ANSI), + _ => None, + } + } + + /// Every role, including the backgrounds it is painted against. + pub fn roles(&self) -> [(&'static str, Color); 26] { + [ + ("card.bg", self.card.bg), + ("card.fg", self.card.fg), + ("card.muted", self.card.muted), + ("card.dim", self.card.dim), + ("card.active", self.card.active), + ("card.idle", self.card.idle), + ("card.awaiting", self.card.awaiting), + ("card.error", self.card.error), + ("selection.bg", self.selection.bg), + ("selection.fg", self.selection.fg), + ("selection.muted", self.selection.muted), + ("selection.dim", self.selection.dim), + ("selection.active", self.selection.active), + ("selection.idle", self.selection.idle), + ("selection.awaiting", self.selection.awaiting), + ("selection.error", self.selection.error), + ("chrome_bg", self.chrome_bg), + ("border_focused", self.border_focused), + ("border_unfocused", self.border_unfocused), + ("focus_accent", self.focus_accent), + ("group_row", self.group_row), + ("mode_normal_bg", self.mode_normal_bg), + ("mode_normal_fg", self.mode_normal_fg), + ("mode_insert_bg", self.mode_insert_bg), + ("mode_insert_fg", self.mode_insert_fg), + ("error_text", self.error_text), + ] + } + /// The surface a card sits on: the selection surface when it is the /// highlighted card, the card surface otherwise. pub fn surface(&self, selected: bool) -> &Surface { @@ -218,9 +339,20 @@ impl Default for Palette { } } -/// The palette in force. Config-driven preset selection lands here. +fn active_slot() -> &'static std::sync::RwLock { + static ACTIVE: std::sync::OnceLock> = std::sync::OnceLock::new(); + ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT)) +} + +/// Set the palette used by the running TUI. +pub(crate) fn set_active(palette: Palette) { + *active_slot().write().expect("active palette lock poisoned") = palette; +} + +/// The palette in force. Config-driven preset selection lands here at TUI +/// startup, before the first frame is drawn. pub fn active() -> Palette { - Palette::DEFAULT + *active_slot().read().expect("active palette lock poisoned") } /// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed @@ -264,30 +396,43 @@ mod tests { /// decoration and only has to clear the 3:1 non-text threshold. #[test] fn every_role_is_legible_on_its_own_surface() { - let p = active(); - for (name, surface) in [("card", &p.card), ("selection", &p.selection)] { - for (role, color) in surface.roles() { - let ratio = contrast_ratio(surface.bg, color) - .unwrap_or_else(|| panic!("{name}.{role} must be an RGB value")); - let floor = if role == "dim" { 3.0 } else { 4.5 }; - assert!( - ratio >= floor, - "{name}.{role} contrast {ratio:.2} is below {floor}" - ); + for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] { + for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] { + for (role, color) in surface.roles() { + let ratio = contrast_ratio(surface.bg, color) + .unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB")); + let floor = if role == "dim" { 3.0 } else { 4.5 }; + assert!( + ratio >= floor, + "{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}" + ); + } } } } + #[test] + fn every_preset_supplies_every_role() { + for (name, p) in Palette::presets() { + assert_eq!(p.roles().len(), 26, "{name} role set is incomplete"); + } + assert_eq!(Palette::named("dark"), Some(Palette::DARK)); + assert_eq!(Palette::named("light"), Some(Palette::LIGHT)); + assert_eq!(Palette::named("ansi"), Some(Palette::ANSI)); + assert_eq!(Palette::named("unknown"), None); + } + /// The selection background exists to be seen as a different surface, not /// merely a slightly different shade of the card. #[test] fn selection_background_is_distinguishable_from_the_card_background() { - let p = active(); - let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap(); - assert!( - ratio >= 1.6, - "card and selection backgrounds differ by only {ratio:.2}" - ); + for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] { + let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap(); + assert!( + ratio >= 1.6, + "{name} card and selection backgrounds differ by only {ratio:.2}" + ); + } } /// The bug this palette fixes: a state colour chosen for the dark card @@ -295,13 +440,14 @@ mod tests { /// selection surface carries its own — distinct — state colours. #[test] fn selection_state_colours_are_not_the_card_state_colours() { - let p = active(); - for state in STATES { - assert_ne!( - p.card.activity(state), - p.selection.activity(state), - "{state:?} must have a selection-specific colour" - ); + for (name, p) in Palette::presets() { + for state in STATES { + assert_ne!( + p.card.activity(state), + p.selection.activity(state), + "{name} {state:?} must have a selection-specific colour" + ); + } } } @@ -310,44 +456,37 @@ mod tests { /// actually painted on; they must never rely on the terminal default. #[test] fn chrome_roles_are_rgb_values() { - let p = active(); - for (name, color) in [ - ("chrome_bg", p.chrome_bg), - ("border_focused", p.border_focused), - ("border_unfocused", p.border_unfocused), - ("focus_accent", p.focus_accent), - ("group_row", p.group_row), - ("mode_normal_bg", p.mode_normal_bg), - ("mode_normal_fg", p.mode_normal_fg), - ("mode_insert_bg", p.mode_insert_bg), - ("mode_insert_fg", p.mode_insert_fg), - ("error_text", p.error_text), - ] { - assert!( - relative_luminance(color).is_some(), - "{name} must be an RGB value" - ); - } - // The mode indicator paints fg on bg; both pairs must stay readable. - for (name, fg, bg) in [ - ("normal", p.mode_normal_fg, p.mode_normal_bg), - ("insert", p.mode_insert_fg, p.mode_insert_bg), - ] { - let ratio = contrast_ratio(fg, bg).unwrap(); - assert!(ratio >= 4.5, "mode {name} contrast {ratio:.2} is below 4.5"); - } - for (name, fg, floor) in [ - ("border_focused", p.border_focused, 3.0), - ("border_unfocused", p.border_unfocused, 3.0), - ("focus_accent", p.focus_accent, 3.0), - ("group_row", p.group_row, 4.5), - ("error_text", p.error_text, 4.5), - ] { - let ratio = contrast_ratio(fg, p.chrome_bg).unwrap(); - assert!( - ratio >= floor, - "{name} contrast {ratio:.2} is below {floor} on chrome_bg" - ); + for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] { + for (name, color) in p.roles().into_iter().skip(16) { + assert!( + relative_luminance(color).is_some(), + "{preset}.{name} must be an RGB value" + ); + } + // The mode indicator paints fg on bg; both pairs must stay readable. + for (name, fg, bg) in [ + ("normal", p.mode_normal_fg, p.mode_normal_bg), + ("insert", p.mode_insert_fg, p.mode_insert_bg), + ] { + let ratio = contrast_ratio(fg, bg).unwrap(); + assert!( + ratio >= 4.5, + "{preset} mode {name} contrast {ratio:.2} is below 4.5" + ); + } + for (name, fg, floor) in [ + ("border_focused", p.border_focused, 3.0), + ("border_unfocused", p.border_unfocused, 3.0), + ("focus_accent", p.focus_accent, 3.0), + ("group_row", p.group_row, 4.5), + ("error_text", p.error_text, 4.5), + ] { + let ratio = contrast_ratio(fg, p.chrome_bg).unwrap(); + assert!( + ratio >= floor, + "{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg" + ); + } } } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 73a966d..c9101bf 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -134,6 +134,7 @@ cover every v1 action — only override what you want to change: ```toml [tui] +theme = "dark" # dark (default), light, or ansi leader = "ctrl-a" # one byte that escapes Insert mode chord_timeout_ms = 1000 # how long to hold a partial chord like `g` @@ -152,6 +153,11 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g` # want extra Insert-mode bindings) ``` +`theme` selects the complete TUI palette. `dark` is the default and follows +Maestro's terminal-native dark product palette; `light` is its light peer; +`ansi` uses the terminal's named ANSI colours for terminals without truecolour. +An unknown name is rejected as an `[tui].theme` configuration error. + The full action vocabulary is the `Action` enum in `crates/maestro-tui/src/action.rs`.