diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index cfe53c4..a4e6e3b 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -180,20 +180,62 @@ pub enum TuiTheme { Light, /// Named ANSI colours, allowing the terminal to provide the colour set. Ansi, + /// Solarized Dark. + SolarizedDark, + /// Solarized Light. + SolarizedLight, + /// Catppuccin Mocha. + Catppuccin, + /// GitHub Dark. + GithubDark, + /// GitHub Light. + GithubLight, + /// Tokyo Night. + TokyoNight, + /// Gruvbox Dark. + Gruvbox, + /// Nord. + Nord, + /// Dracula. + Dracula, } +/// Every `[tui].theme` name and its variant, in documentation order. +pub const TUI_THEMES: &[(&str, TuiTheme)] = &[ + ("dark", TuiTheme::Dark), + ("light", TuiTheme::Light), + ("ansi", TuiTheme::Ansi), + ("solarized-dark", TuiTheme::SolarizedDark), + ("solarized-light", TuiTheme::SolarizedLight), + ("catppuccin", TuiTheme::Catppuccin), + ("github-dark", TuiTheme::GithubDark), + ("github-light", TuiTheme::GithubLight), + ("tokyo-night", TuiTheme::TokyoNight), + ("gruvbox", TuiTheme::Gruvbox), + ("nord", TuiTheme::Nord), + ("dracula", TuiTheme::Dracula), +]; + 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\"" - ))), + raw.map(|name| { + TUI_THEMES + .iter() + .find(|(n, _)| *n == name) + .map(|(_, theme)| *theme) + .ok_or_else(|| { + let known = TUI_THEMES + .iter() + .map(|(n, _)| format!("{n:?}")) + .collect::>() + .join(", "); + D::Error::custom(format!( + "invalid [tui].theme {name:?}; expected one of {known}" + )) + }) }) .transpose() } @@ -630,23 +672,22 @@ 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)); + for (name, theme) in TUI_THEMES { + let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n")); + assert_eq!(cfg.tui.theme, Some(*theme)); + } } #[test] fn tui_theme_rejects_unknown_names_with_the_field_name() { - let err = - toml::from_str::("[tui]\ntheme = \"solarized\"\n").unwrap_err(); + let err = toml::from_str::("[tui]\ntheme = \"glorb\"\n").unwrap_err(); let message = err.to_string(); assert!( message.contains("[tui].theme"), "diagnostic should name the field: {message}" ); assert!( - message.contains("solarized"), + message.contains("glorb"), "diagnostic should name the invalid value: {message}" ); } diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 00e4678..4b76be7 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -78,6 +78,15 @@ impl TuiConfig { TuiTheme::Dark => Palette::DARK, TuiTheme::Light => Palette::LIGHT, TuiTheme::Ansi => Palette::ANSI, + TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK, + TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT, + TuiTheme::Catppuccin => Palette::CATPPUCCIN, + TuiTheme::GithubDark => Palette::GITHUB_DARK, + TuiTheme::GithubLight => Palette::GITHUB_LIGHT, + TuiTheme::TokyoNight => Palette::TOKYO_NIGHT, + TuiTheme::Gruvbox => Palette::GRUVBOX, + TuiTheme::Nord => Palette::NORD, + TuiTheme::Dracula => Palette::DRACULA, }; let leader_str = section.leader.as_deref().unwrap_or("ctrl-a"); let leader = InputKey::parse(leader_str) @@ -161,6 +170,15 @@ mod tests { (TuiTheme::Dark, Palette::DARK), (TuiTheme::Light, Palette::LIGHT), (TuiTheme::Ansi, Palette::ANSI), + (TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK), + (TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT), + (TuiTheme::Catppuccin, Palette::CATPPUCCIN), + (TuiTheme::GithubDark, Palette::GITHUB_DARK), + (TuiTheme::GithubLight, Palette::GITHUB_LIGHT), + (TuiTheme::TokyoNight, Palette::TOKYO_NIGHT), + (TuiTheme::Gruvbox, Palette::GRUVBOX), + (TuiTheme::Nord, Palette::NORD), + (TuiTheme::Dracula, Palette::DRACULA), ] { let section = TuiSection { theme: Some(theme), diff --git a/crates/maestro-tui/src/palette.rs b/crates/maestro-tui/src/palette.rs index 5c7ec1d..8f688d5 100644 --- a/crates/maestro-tui/src/palette.rs +++ b/crates/maestro-tui/src/palette.rs @@ -17,7 +17,11 @@ //! keeps carrying the state either way (docs/SIDEBAR.md). //! //! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and -//! [`Palette::ANSI`] uses terminal-provided ANSI colours. +//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining +//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized, +//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual +//! colours lifted or darkened where the originals fall short of the contrast +//! floors asserted below. //! //! Out of scope: PTY cell colours. Those belong to the program running in the //! pane and pass through `pane::translate_color` verbatim. @@ -236,26 +240,349 @@ impl Palette { error_text: Color::LightRed, }; + /// Solarized Dark — Ethan Schoonover's low-contrast classic, + /// accents lifted where the originals fall short of WCAG AA. + pub const SOLARIZED_DARK: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x00, 0x2b, 0x36), + fg: Color::Rgb(0x93, 0xa1, 0xa1), + muted: Color::Rgb(0x83, 0x94, 0x96), + dim: Color::Rgb(0x65, 0x7b, 0x83), + active: Color::Rgb(0x85, 0x99, 0x00), + idle: Color::Rgb(0x83, 0x94, 0x96), + awaiting: Color::Rgb(0xb5, 0x89, 0x00), + error: Color::Rgb(0xe5, 0x68, 0x65), + }, + selection: Surface { + bg: Color::Rgb(0x10, 0x53, 0x62), + fg: Color::Rgb(0xb6, 0xc0, 0xc0), + muted: Color::Rgb(0xb5, 0xc0, 0xc1), + dim: Color::Rgb(0x8b, 0x9f, 0xa6), + active: Color::Rgb(0xaf, 0xca, 0x00), + idle: Color::Rgb(0xb5, 0xc0, 0xc1), + awaiting: Color::Rgb(0xef, 0xb5, 0x00), + error: Color::Rgb(0xf1, 0xad, 0xab), + }, + chrome_bg: Color::Rgb(0x00, 0x2b, 0x36), + sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21), + border_focused: Color::Rgb(0x2a, 0xa1, 0x98), + border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e), + focus_accent: Color::Rgb(0x2a, 0xa1, 0x98), + group_row: Color::Rgb(0x93, 0xa1, 0xa1), + mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98), + mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36), + mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00), + mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36), + error_text: Color::Rgb(0xe5, 0x68, 0x65), + }; + + /// Solarized Light — the warm cream-paper peer of Solarized Dark. + pub const SOLARIZED_LIGHT: Palette = Palette { + card: Surface { + bg: Color::Rgb(0xfd, 0xf6, 0xe3), + fg: Color::Rgb(0x58, 0x6e, 0x75), + muted: Color::Rgb(0x5e, 0x73, 0x7a), + dim: Color::Rgb(0x7e, 0x8f, 0x92), + active: Color::Rgb(0x67, 0x76, 0x00), + idle: Color::Rgb(0x5e, 0x73, 0x7a), + awaiting: Color::Rgb(0x8d, 0x6a, 0x00), + error: Color::Rgb(0xd7, 0x27, 0x24), + }, + selection: Surface { + bg: Color::Rgb(0xcb, 0xbd, 0x93), + fg: Color::Rgb(0x3f, 0x4e, 0x53), + muted: Color::Rgb(0x40, 0x4e, 0x53), + dim: Color::Rgb(0x59, 0x67, 0x69), + active: Color::Rgb(0x46, 0x50, 0x00), + idle: Color::Rgb(0x40, 0x4e, 0x53), + awaiting: Color::Rgb(0x60, 0x48, 0x00), + error: Color::Rgb(0x94, 0x1b, 0x19), + }, + chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3), + sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed), + border_focused: Color::Rgb(0x29, 0x9b, 0x93), + border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f), + focus_accent: Color::Rgb(0x29, 0x9b, 0x93), + group_row: Color::Rgb(0x58, 0x6e, 0x75), + mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98), + mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36), + mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00), + mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3), + error_text: Color::Rgb(0xd7, 0x27, 0x24), + }; + + /// Catppuccin Mocha — soothing pastels on a soft dark base. + pub const CATPPUCCIN: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x1e, 0x1e, 0x2e), + fg: Color::Rgb(0xcd, 0xd6, 0xf4), + muted: Color::Rgb(0xa6, 0xad, 0xc8), + dim: Color::Rgb(0x7f, 0x84, 0x9c), + active: Color::Rgb(0xa6, 0xe3, 0xa1), + idle: Color::Rgb(0xa6, 0xad, 0xc8), + awaiting: Color::Rgb(0xf9, 0xe2, 0xaf), + error: Color::Rgb(0xf3, 0x8b, 0xa8), + }, + selection: Surface { + bg: Color::Rgb(0x45, 0x47, 0x5a), + fg: Color::Rgb(0xcd, 0xd6, 0xf4), + muted: Color::Rgb(0xb2, 0xb8, 0xcf), + dim: Color::Rgb(0x92, 0x97, 0xab), + active: Color::Rgb(0xb5, 0xe8, 0xb1), + idle: Color::Rgb(0xb2, 0xb8, 0xcf), + awaiting: Color::Rgb(0xfa, 0xe9, 0xc2), + error: Color::Rgb(0xf5, 0xa1, 0xb8), + }, + chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e), + sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c), + border_focused: Color::Rgb(0xcb, 0xa6, 0xf7), + border_unfocused: Color::Rgb(0x6c, 0x70, 0x86), + focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7), + group_row: Color::Rgb(0xb4, 0xbe, 0xfe), + mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7), + mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e), + mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf), + mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e), + error_text: Color::Rgb(0xf3, 0x8b, 0xa8), + }; + + /// GitHub Dark — the github.com dark default. + pub const GITHUB_DARK: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x0d, 0x11, 0x17), + fg: Color::Rgb(0xe6, 0xed, 0xf3), + muted: Color::Rgb(0x91, 0x98, 0xa1), + dim: Color::Rgb(0x6e, 0x76, 0x81), + active: Color::Rgb(0x3f, 0xb9, 0x50), + idle: Color::Rgb(0x91, 0x98, 0xa1), + awaiting: Color::Rgb(0xd2, 0x99, 0x22), + error: Color::Rgb(0xf8, 0x51, 0x49), + }, + selection: Surface { + bg: Color::Rgb(0x1f, 0x3a, 0x5f), + fg: Color::Rgb(0xe6, 0xed, 0xf3), + muted: Color::Rgb(0x9f, 0xa5, 0xad), + dim: Color::Rgb(0x7e, 0x86, 0x91), + active: Color::Rgb(0x40, 0xbc, 0x51), + idle: Color::Rgb(0x9f, 0xa5, 0xad), + awaiting: Color::Rgb(0xd4, 0x9b, 0x22), + error: Color::Rgb(0xfa, 0x81, 0x7b), + }, + chrome_bg: Color::Rgb(0x0d, 0x11, 0x17), + sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26), + border_focused: Color::Rgb(0x58, 0xa6, 0xff), + border_unfocused: Color::Rgb(0x58, 0x64, 0x70), + focus_accent: Color::Rgb(0x58, 0xa6, 0xff), + group_row: Color::Rgb(0x91, 0x98, 0xa1), + mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff), + mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17), + mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22), + mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17), + error_text: Color::Rgb(0xf8, 0x51, 0x49), + }; + + /// GitHub Light — the github.com light default. + pub const GITHUB_LIGHT: Palette = Palette { + card: Surface { + bg: Color::Rgb(0xff, 0xff, 0xff), + fg: Color::Rgb(0x1f, 0x23, 0x28), + muted: Color::Rgb(0x59, 0x63, 0x6e), + dim: Color::Rgb(0x81, 0x8b, 0x98), + active: Color::Rgb(0x1a, 0x7f, 0x37), + idle: Color::Rgb(0x59, 0x63, 0x6e), + awaiting: Color::Rgb(0x9a, 0x67, 0x00), + error: Color::Rgb(0xd1, 0x24, 0x2f), + }, + selection: Surface { + bg: Color::Rgb(0xa5, 0xcd, 0xf0), + fg: Color::Rgb(0x1f, 0x23, 0x28), + muted: Color::Rgb(0x4b, 0x53, 0x5d), + dim: Color::Rgb(0x64, 0x6d, 0x7a), + active: Color::Rgb(0x14, 0x5f, 0x29), + idle: Color::Rgb(0x4b, 0x53, 0x5d), + awaiting: Color::Rgb(0x71, 0x4b, 0x00), + error: Color::Rgb(0xa1, 0x1c, 0x24), + }, + chrome_bg: Color::Rgb(0xff, 0xff, 0xff), + sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc), + border_focused: Color::Rgb(0x09, 0x69, 0xda), + border_unfocused: Color::Rgb(0x89, 0x93, 0x9d), + focus_accent: Color::Rgb(0x09, 0x69, 0xda), + group_row: Color::Rgb(0x59, 0x63, 0x6e), + mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda), + mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff), + mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00), + mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff), + error_text: Color::Rgb(0xd1, 0x24, 0x2f), + }; + + /// Tokyo Night — neon-tinted blues of the VS Code / vim favourite. + pub const TOKYO_NIGHT: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x1a, 0x1b, 0x26), + fg: Color::Rgb(0xc0, 0xca, 0xf5), + muted: Color::Rgb(0xa9, 0xb1, 0xd6), + dim: Color::Rgb(0x5d, 0x67, 0x95), + active: Color::Rgb(0x9e, 0xce, 0x6a), + idle: Color::Rgb(0xa9, 0xb1, 0xd6), + awaiting: Color::Rgb(0xe0, 0xaf, 0x68), + error: Color::Rgb(0xf7, 0x76, 0x8e), + }, + selection: Surface { + bg: Color::Rgb(0x33, 0x46, 0x7c), + fg: Color::Rgb(0xc0, 0xca, 0xf5), + muted: Color::Rgb(0xb0, 0xb8, 0xd9), + dim: Color::Rgb(0x8e, 0x96, 0xb8), + active: Color::Rgb(0xa8, 0xd3, 0x79), + idle: Color::Rgb(0xb0, 0xb8, 0xd9), + awaiting: Color::Rgb(0xe1, 0xb1, 0x6b), + error: Color::Rgb(0xf9, 0xa0, 0xb0), + }, + chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26), + sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b), + border_focused: Color::Rgb(0x7a, 0xa2, 0xf7), + border_unfocused: Color::Rgb(0x5d, 0x67, 0x95), + focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7), + group_row: Color::Rgb(0xa9, 0xb1, 0xd6), + mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7), + mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26), + mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68), + mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26), + error_text: Color::Rgb(0xf7, 0x76, 0x8e), + }; + + /// Gruvbox Dark — retro warm earth tones. + pub const GRUVBOX: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x28, 0x28, 0x28), + fg: Color::Rgb(0xeb, 0xdb, 0xb2), + muted: Color::Rgb(0xbd, 0xae, 0x93), + dim: Color::Rgb(0x92, 0x83, 0x74), + active: Color::Rgb(0xb8, 0xbb, 0x26), + idle: Color::Rgb(0xbd, 0xae, 0x93), + awaiting: Color::Rgb(0xfa, 0xbd, 0x2f), + error: Color::Rgb(0xfb, 0x58, 0x44), + }, + selection: Surface { + bg: Color::Rgb(0x50, 0x49, 0x45), + fg: Color::Rgb(0xeb, 0xdb, 0xb2), + muted: Color::Rgb(0xc7, 0xbb, 0xa4), + dim: Color::Rgb(0xa4, 0x98, 0x8b), + active: Color::Rgb(0xbf, 0xc3, 0x28), + idle: Color::Rgb(0xc7, 0xbb, 0xa4), + awaiting: Color::Rgb(0xfa, 0xc3, 0x43), + error: Color::Rgb(0xfd, 0xa5, 0x9a), + }, + chrome_bg: Color::Rgb(0x28, 0x28, 0x28), + sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f), + border_focused: Color::Rgb(0xfe, 0x80, 0x19), + border_unfocused: Color::Rgb(0x7e, 0x71, 0x67), + focus_accent: Color::Rgb(0xfe, 0x80, 0x19), + group_row: Color::Rgb(0xd5, 0xc4, 0xa1), + mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19), + mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28), + mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f), + mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28), + error_text: Color::Rgb(0xfb, 0x58, 0x44), + }; + + /// Nord — arctic, bluish and calm; the red lifted to stay legible. + pub const NORD: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x2e, 0x34, 0x40), + fg: Color::Rgb(0xec, 0xef, 0xf4), + muted: Color::Rgb(0xd8, 0xde, 0xe9), + dim: Color::Rgb(0x9a, 0xa5, 0xb5), + active: Color::Rgb(0xa3, 0xbe, 0x8c), + idle: Color::Rgb(0xd8, 0xde, 0xe9), + awaiting: Color::Rgb(0xeb, 0xcb, 0x8b), + error: Color::Rgb(0xd0, 0x8b, 0x91), + }, + selection: Surface { + bg: Color::Rgb(0x4c, 0x56, 0x6a), + fg: Color::Rgb(0xec, 0xef, 0xf4), + muted: Color::Rgb(0xd8, 0xde, 0xe9), + dim: Color::Rgb(0x9f, 0xaa, 0xb9), + active: Color::Rgb(0xc0, 0xd3, 0xb0), + idle: Color::Rgb(0xe5, 0xe9, 0xf0), + awaiting: Color::Rgb(0xee, 0xd3, 0x9c), + error: Color::Rgb(0xe7, 0xc5, 0xc8), + }, + chrome_bg: Color::Rgb(0x2e, 0x34, 0x40), + sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49), + border_focused: Color::Rgb(0x88, 0xc0, 0xd0), + border_unfocused: Color::Rgb(0x72, 0x80, 0x9a), + focus_accent: Color::Rgb(0x88, 0xc0, 0xd0), + group_row: Color::Rgb(0xd8, 0xde, 0xe9), + mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0), + mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40), + mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b), + mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40), + error_text: Color::Rgb(0xd0, 0x8b, 0x91), + }; + + /// Dracula — high-saturation accents on a dark violet-grey base. + pub const DRACULA: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x28, 0x2a, 0x36), + fg: Color::Rgb(0xf8, 0xf8, 0xf2), + muted: Color::Rgb(0xbf, 0xc3, 0xd9), + dim: Color::Rgb(0x65, 0x74, 0xa6), + active: Color::Rgb(0x50, 0xfa, 0x7b), + idle: Color::Rgb(0xbf, 0xc3, 0xd9), + awaiting: Color::Rgb(0xf1, 0xfa, 0x8c), + error: Color::Rgb(0xff, 0x59, 0x59), + }, + selection: Surface { + bg: Color::Rgb(0x4a, 0x4e, 0x62), + fg: Color::Rgb(0xf8, 0xf8, 0xf2), + muted: Color::Rgb(0xbf, 0xc3, 0xd9), + dim: Color::Rgb(0x94, 0x9f, 0xc1), + active: Color::Rgb(0x64, 0xfb, 0x8a), + idle: Color::Rgb(0xcc, 0xcf, 0xe1), + awaiting: Color::Rgb(0xf3, 0xfb, 0xa0), + error: Color::Rgb(0xff, 0xad, 0xad), + }, + chrome_bg: Color::Rgb(0x28, 0x2a, 0x36), + sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42), + border_focused: Color::Rgb(0xbd, 0x93, 0xf9), + border_unfocused: Color::Rgb(0x65, 0x74, 0xa6), + focus_accent: Color::Rgb(0xbd, 0x93, 0xf9), + group_row: Color::Rgb(0xbf, 0xc3, 0xd9), + mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9), + mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36), + mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c), + mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36), + error_text: Color::Rgb(0xff, 0x59, 0x59), + }; + /// 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] { + pub fn presets() -> [(&'static str, Palette); 12] { [ ("dark", Self::DARK), ("light", Self::LIGHT), ("ansi", Self::ANSI), + ("solarized-dark", Self::SOLARIZED_DARK), + ("solarized-light", Self::SOLARIZED_LIGHT), + ("catppuccin", Self::CATPPUCCIN), + ("github-dark", Self::GITHUB_DARK), + ("github-light", Self::GITHUB_LIGHT), + ("tokyo-night", Self::TOKYO_NIGHT), + ("gruvbox", Self::GRUVBOX), + ("nord", Self::NORD), + ("dracula", Self::DRACULA), ] } /// 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, - } + Self::presets() + .into_iter() + .find(|(n, _)| *n == name) + .map(|(_, p)| p) } /// Every role, including the backgrounds it is painted against. @@ -398,11 +725,19 @@ mod tests { ActivityState::Error, ]; + /// Every preset except `ansi`, whose colours the terminal owns and which + /// therefore has no fixed luminance to assert against. + fn rgb_presets() -> impl Iterator { + Palette::presets() + .into_iter() + .filter(|(_, p)| relative_luminance(p.card.bg).is_some()) + } + /// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is /// decoration and only has to clear the 3:1 non-text threshold. #[test] fn every_role_is_legible_on_its_own_surface() { - for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] { + for (preset, p) in rgb_presets() { for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] { for (role, color) in surface.roles() { let ratio = contrast_ratio(surface.bg, color) @@ -421,10 +756,8 @@ mod tests { fn every_preset_supplies_every_role() { for (name, p) in Palette::presets() { assert_eq!(p.roles().len(), 27, "{name} role set is incomplete"); + assert_eq!(Palette::named(name), Some(p)); } - 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); } @@ -432,7 +765,7 @@ mod tests { /// merely a slightly different shade of the card. #[test] fn selection_background_is_distinguishable_from_the_card_background() { - for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] { + for (name, p) in rgb_presets() { let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap(); assert!( ratio >= 1.6, @@ -462,7 +795,7 @@ mod tests { /// actually painted on; they must never rely on the terminal default. #[test] fn chrome_roles_are_rgb_values() { - for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] { + for (preset, p) in rgb_presets() { for (name, color) in p.roles().into_iter().skip(16) { assert!( relative_luminance(color).is_some(), diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 68822b0..8d600d3 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -134,7 +134,7 @@ cover every v1 action — only override what you want to change: ```toml [tui] -theme = "dark" # dark (default), light, or ansi +theme = "dark" # see the theme list below leader = "ctrl-a" # one byte that escapes Insert mode chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` @@ -154,6 +154,22 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` `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. +The remaining presets adapt well-known editor themes, with individual colours +lifted or darkened where the originals fall short of the WCAG AA contrast +floors the palette tests assert: + +| name | based on | +|-------------------|------------------| +| `solarized-dark` | Solarized Dark | +| `solarized-light` | Solarized Light | +| `catppuccin` | Catppuccin Mocha | +| `github-dark` | GitHub Dark | +| `github-light` | GitHub Light | +| `tokyo-night` | Tokyo Night | +| `gruvbox` | Gruvbox Dark | +| `nord` | Nord | +| `dracula` | Dracula | + An unknown name is rejected as an `[tui].theme` configuration error. The full action vocabulary is the `Action` enum in