Add Catppuccin/Tokyo Night/Dracula/Gruvbox themes + /theme picker
Wires the previously-dormant `theme` setting (#657 follow-up) into the live Settings struct so the choice survives restart. `/theme` opens an interactive picker with live preview; `/theme <name>` switches and persists non-interactively. Most render sites use bare `palette::TEXT_BODY` / `DEEPSEEK_INK` / `BORDER_COLOR` constants rather than reading `app.ui_theme`, so simply adding new UiTheme variants only repaints ~20% of surfaces. The fix is a third stage in ColorCompatBackend (alongside the existing dark<->light and truecolor<->256 stages) that rewrites every well-known dark-palette constant to the corresponding UiTheme slot for the active preset. The remap is a no-op for System / Whale / WhaleLight, so legacy dark/light flows stay byte-identical. Settings: theme = system | dark | light | catppuccin-mocha | tokyo-night | dracula | gruvbox-dark. Unknown values normalise to system. background_color still overlays on top. Tests: new coverage in theme_picker and palette; pinned make_app() in footer tests to ThemeId::System after App::new (matching the existing default_model pin) since App::new now honors settings.theme.
This commit is contained in:
@@ -419,7 +419,21 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"background_color" | "background" | "bg" => {
|
||||
let base_theme = crate::palette::UiTheme::detect();
|
||||
let base_theme = crate::palette::ThemeId::from_name(&settings.theme)
|
||||
.unwrap_or(crate::palette::ThemeId::System)
|
||||
.ui_theme();
|
||||
app.ui_theme = settings
|
||||
.background_color
|
||||
.as_deref()
|
||||
.and_then(crate::palette::parse_hex_rgb_color)
|
||||
.map_or(base_theme, |color| base_theme.with_background_color(color));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"theme" => {
|
||||
let theme_id = crate::palette::ThemeId::from_name(&settings.theme)
|
||||
.unwrap_or(crate::palette::ThemeId::System);
|
||||
let base_theme = theme_id.ui_theme();
|
||||
app.theme_id = theme_id;
|
||||
app.ui_theme = settings
|
||||
.background_color
|
||||
.as_deref()
|
||||
@@ -580,22 +594,15 @@ fn mode_display_name(mode: AppMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle between dark and light theme.
|
||||
pub fn theme(app: &mut App) -> CommandResult {
|
||||
let new_theme = match app.ui_theme.mode {
|
||||
crate::palette::PaletteMode::Dark => {
|
||||
crate::palette::UiTheme::for_mode(crate::palette::PaletteMode::Light)
|
||||
}
|
||||
crate::palette::PaletteMode::Light => {
|
||||
crate::palette::UiTheme::for_mode(crate::palette::PaletteMode::Dark)
|
||||
}
|
||||
};
|
||||
app.ui_theme = new_theme;
|
||||
let label = match new_theme.mode {
|
||||
crate::palette::PaletteMode::Dark => "dark",
|
||||
crate::palette::PaletteMode::Light => "light",
|
||||
};
|
||||
CommandResult::message(format!("Theme switched to {label}."))
|
||||
/// `/theme [name]` — with no argument, open the interactive picker (arrow
|
||||
/// keys, live preview, Enter to persist, Esc to revert). With an argument,
|
||||
/// route through `set_config_value("theme", ...)` so the apply + save flow is
|
||||
/// shared with `/config`.
|
||||
pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => CommandResult::action(AppAction::OpenThemePicker),
|
||||
Some(name) => set_config_value(app, "theme", name, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Manage workspace-level trust and the per-path allowlist.
|
||||
|
||||
@@ -341,7 +341,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "theme",
|
||||
aliases: &[],
|
||||
usage: "/theme",
|
||||
usage: "/theme [name]",
|
||||
description_id: MessageId::CmdThemeDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
@@ -562,7 +562,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"status" => status::status(app),
|
||||
"statusline" => config::status_line(app),
|
||||
"mode" => config::mode(app, arg),
|
||||
"theme" => config::theme(app),
|
||||
"theme" => config::theme(app, arg),
|
||||
"verbose" => config::verbose(app, arg),
|
||||
"trust" => config::trust(app, arg),
|
||||
"logout" => config::logout(app),
|
||||
|
||||
@@ -278,6 +278,213 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
border: LIGHT_BORDER,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Community theme presets. All four are dark; selecting any of them implies
|
||||
// `PaletteMode::Dark` so the existing dark/light adapt_*_for_palette_mode
|
||||
// helpers continue to apply without surprise. Hex values come from each
|
||||
// project's published spec.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
|
||||
name: "catppuccin-mocha",
|
||||
mode: PaletteMode::Dark,
|
||||
surface_bg: Color::Rgb(0x1e, 0x1e, 0x2e), // base
|
||||
panel_bg: Color::Rgb(0x18, 0x18, 0x25), // mantle
|
||||
elevated_bg: Color::Rgb(0x31, 0x32, 0x44), // surface0
|
||||
composer_bg: Color::Rgb(0x18, 0x18, 0x25),
|
||||
selection_bg: Color::Rgb(0x45, 0x47, 0x5a), // surface1
|
||||
header_bg: Color::Rgb(0x11, 0x11, 0x1b), // crust
|
||||
footer_bg: Color::Rgb(0x11, 0x11, 0x1b),
|
||||
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue
|
||||
mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red
|
||||
mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach
|
||||
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
|
||||
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
|
||||
text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0
|
||||
text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0
|
||||
text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text
|
||||
text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1
|
||||
border: Color::Rgb(0x45, 0x47, 0x5a), // surface1
|
||||
};
|
||||
|
||||
pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
name: "tokyo-night",
|
||||
mode: PaletteMode::Dark,
|
||||
surface_bg: Color::Rgb(0x1a, 0x1b, 0x26), // bg
|
||||
panel_bg: Color::Rgb(0x16, 0x16, 0x1e), // bg_dark
|
||||
elevated_bg: Color::Rgb(0x29, 0x2e, 0x42), // bg_highlight
|
||||
composer_bg: Color::Rgb(0x16, 0x16, 0x1e),
|
||||
selection_bg: Color::Rgb(0x28, 0x34, 0x57), // visual selection
|
||||
header_bg: Color::Rgb(0x16, 0x16, 0x1e),
|
||||
footer_bg: Color::Rgb(0x16, 0x16, 0x1e),
|
||||
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
|
||||
mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red
|
||||
mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange
|
||||
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
|
||||
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
|
||||
text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5
|
||||
text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark
|
||||
text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg
|
||||
text_soft: Color::Rgb(0xbb, 0xc2, 0xe0),
|
||||
border: Color::Rgb(0x41, 0x48, 0x68), // terminal_black
|
||||
};
|
||||
|
||||
pub const DRACULA_UI_THEME: UiTheme = UiTheme {
|
||||
name: "dracula",
|
||||
mode: PaletteMode::Dark,
|
||||
surface_bg: Color::Rgb(0x28, 0x2a, 0x36), // background
|
||||
panel_bg: Color::Rgb(0x21, 0x22, 0x2c),
|
||||
elevated_bg: Color::Rgb(0x34, 0x37, 0x46),
|
||||
composer_bg: Color::Rgb(0x21, 0x22, 0x2c),
|
||||
selection_bg: Color::Rgb(0x44, 0x47, 0x5a), // current line
|
||||
header_bg: Color::Rgb(0x21, 0x22, 0x2c),
|
||||
footer_bg: Color::Rgb(0x21, 0x22, 0x2c),
|
||||
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple
|
||||
mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange
|
||||
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
|
||||
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
|
||||
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
|
||||
text_dim: Color::Rgb(0x62, 0x72, 0xa4),
|
||||
text_hint: Color::Rgb(0x8a, 0x8e, 0xaa),
|
||||
text_muted: Color::Rgb(0xc0, 0xc4, 0xd6),
|
||||
text_body: Color::Rgb(0xf8, 0xf8, 0xf2), // foreground
|
||||
text_soft: Color::Rgb(0xe2, 0xe2, 0xdc),
|
||||
border: Color::Rgb(0x44, 0x47, 0x5a),
|
||||
};
|
||||
|
||||
pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
|
||||
name: "gruvbox-dark",
|
||||
mode: PaletteMode::Dark,
|
||||
surface_bg: Color::Rgb(0x28, 0x28, 0x28), // bg0
|
||||
panel_bg: Color::Rgb(0x3c, 0x38, 0x36), // bg1
|
||||
elevated_bg: Color::Rgb(0x50, 0x49, 0x45), // bg2
|
||||
composer_bg: Color::Rgb(0x3c, 0x38, 0x36),
|
||||
selection_bg: Color::Rgb(0x66, 0x5c, 0x54), // bg3
|
||||
header_bg: Color::Rgb(0x1d, 0x20, 0x21), // bg0_h
|
||||
footer_bg: Color::Rgb(0x1d, 0x20, 0x21),
|
||||
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue
|
||||
mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red
|
||||
mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange
|
||||
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
|
||||
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
|
||||
text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4
|
||||
text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3
|
||||
text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1
|
||||
text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2
|
||||
border: Color::Rgb(0x66, 0x5c, 0x54), // bg3
|
||||
};
|
||||
|
||||
/// Stable identifiers for the named themes the user can select. `System`
|
||||
/// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each
|
||||
/// dark/light id resolves to a single fixed `UiTheme`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThemeId {
|
||||
System,
|
||||
Whale,
|
||||
WhaleLight,
|
||||
CatppuccinMocha,
|
||||
TokyoNight,
|
||||
Dracula,
|
||||
GruvboxDark,
|
||||
}
|
||||
|
||||
impl ThemeId {
|
||||
/// Parse a settings string (`"system"`, `"dark"`, `"catppuccin-mocha"`, …).
|
||||
/// Accepts a few aliases (`"whale"` for dark, `"light"` for whale-light)
|
||||
/// so existing config files keep working. Case-insensitive.
|
||||
#[must_use]
|
||||
pub fn from_name(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"system" | "auto" => Some(Self::System),
|
||||
"dark" | "whale" => Some(Self::Whale),
|
||||
"light" | "whale-light" => Some(Self::WhaleLight),
|
||||
"catppuccin-mocha" | "catppuccin" | "mocha" => Some(Self::CatppuccinMocha),
|
||||
"tokyo-night" | "tokyonight" => Some(Self::TokyoNight),
|
||||
"dracula" => Some(Self::Dracula),
|
||||
"gruvbox-dark" | "gruvbox" => Some(Self::GruvboxDark),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical settings string (lowercase, dash-separated). Round-trips
|
||||
/// through `from_name`.
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Whale => "dark",
|
||||
Self::WhaleLight => "light",
|
||||
Self::CatppuccinMocha => "catppuccin-mocha",
|
||||
Self::TokyoNight => "tokyo-night",
|
||||
Self::Dracula => "dracula",
|
||||
Self::GruvboxDark => "gruvbox-dark",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label for picker rows.
|
||||
#[must_use]
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Whale => "Whale (Dark)",
|
||||
Self::WhaleLight => "Whale Light",
|
||||
Self::CatppuccinMocha => "Catppuccin Mocha",
|
||||
Self::TokyoNight => "Tokyo Night",
|
||||
Self::Dracula => "Dracula",
|
||||
Self::GruvboxDark => "Gruvbox Dark",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short tagline for picker rows.
|
||||
#[must_use]
|
||||
pub const fn tagline(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Follow terminal background (COLORFGBG)",
|
||||
Self::Whale => "Default DeepSeek dark blue",
|
||||
Self::WhaleLight => "DeepSeek light, paper-ish",
|
||||
Self::CatppuccinMocha => "Soft pastels on warm dark",
|
||||
Self::TokyoNight => "Deep blue/violet night palette",
|
||||
Self::Dracula => "Classic high-contrast purple",
|
||||
Self::GruvboxDark => "Vintage warm earth tones",
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve to a concrete `UiTheme`. For `System` this consults
|
||||
/// `PaletteMode::detect()` exactly once and returns the corresponding
|
||||
/// dark/light theme — callers that want to live-track terminal background
|
||||
/// changes need to re-invoke this.
|
||||
#[must_use]
|
||||
pub fn ui_theme(self) -> UiTheme {
|
||||
match self {
|
||||
Self::System => UiTheme::detect(),
|
||||
Self::Whale => UI_THEME,
|
||||
Self::WhaleLight => LIGHT_UI_THEME,
|
||||
Self::CatppuccinMocha => CATPPUCCIN_MOCHA_UI_THEME,
|
||||
Self::TokyoNight => TOKYO_NIGHT_UI_THEME,
|
||||
Self::Dracula => DRACULA_UI_THEME,
|
||||
Self::GruvboxDark => GRUVBOX_DARK_UI_THEME,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Themes shown in the `/theme` picker, in display order.
|
||||
pub const SELECTABLE_THEMES: &[ThemeId] = &[
|
||||
ThemeId::System,
|
||||
ThemeId::Whale,
|
||||
ThemeId::WhaleLight,
|
||||
ThemeId::CatppuccinMocha,
|
||||
ThemeId::TokyoNight,
|
||||
ThemeId::Dracula,
|
||||
ThemeId::GruvboxDark,
|
||||
];
|
||||
|
||||
impl UiTheme {
|
||||
#[must_use]
|
||||
pub fn for_mode(mode: PaletteMode) -> Self {
|
||||
@@ -394,6 +601,159 @@ pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
// === Community-theme remap ===
|
||||
//
|
||||
// The vast majority of render sites in this crate reach for `palette::TEXT_*`,
|
||||
// `palette::DEEPSEEK_INK`, `palette::BORDER_COLOR`, etc. directly rather than
|
||||
// looking up `app.ui_theme`. To make community theme presets (Catppuccin,
|
||||
// Tokyo Night, …) actually move the needle visually we intercept colors at
|
||||
// the backend layer (see `tui::color_compat::ColorCompatBackend`) and remap
|
||||
// every well-known dark-palette constant to the equivalent UiTheme slot for
|
||||
// the active preset. For `System`, `Whale`, and `WhaleLight` the remap is a
|
||||
// no-op — the existing dark/light pipeline handles those.
|
||||
|
||||
/// Per-preset green accent used for things that semantically *should* stay
|
||||
/// green even after theming (diff "+" lines, user-input body). Mapping these
|
||||
/// to `ui.status_working` would lose the green/cyan distinction the UI
|
||||
/// relies on, so we keep a small dedicated table.
|
||||
#[must_use]
|
||||
const fn theme_green(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0xa6, 0xe3, 0xa1),
|
||||
ThemeId::TokyoNight => Color::Rgb(0x9e, 0xce, 0x6a),
|
||||
ThemeId::Dracula => Color::Rgb(0x50, 0xfa, 0x7b),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0xb8, 0xbb, 0x26),
|
||||
_ => USER_BODY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-preset red accent, used for diff "−" line foreground when present.
|
||||
#[must_use]
|
||||
const fn theme_red(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||
ThemeId::TokyoNight => Color::Rgb(0xf7, 0x76, 0x8e),
|
||||
ThemeId::Dracula => Color::Rgb(0xff, 0x55, 0x55),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0xfb, 0x49, 0x34),
|
||||
_ => DEEPSEEK_RED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-preset dark-green diff-added background tint.
|
||||
#[must_use]
|
||||
const fn theme_diff_added_bg(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0x1f, 0x33, 0x29),
|
||||
ThemeId::TokyoNight => Color::Rgb(0x1b, 0x2b, 0x1f),
|
||||
ThemeId::Dracula => Color::Rgb(0x21, 0x3a, 0x2a),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0x29, 0x32, 0x16),
|
||||
_ => DIFF_ADDED_BG,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-preset dark-red diff-deleted background tint.
|
||||
#[must_use]
|
||||
const fn theme_diff_deleted_bg(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0x3a, 0x1f, 0x2a),
|
||||
ThemeId::TokyoNight => Color::Rgb(0x33, 0x1c, 0x24),
|
||||
ThemeId::Dracula => Color::Rgb(0x3a, 0x1f, 0x22),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0x35, 0x1c, 0x18),
|
||||
_ => DIFF_DELETED_BG,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the preset participates in the cell-level remap. The
|
||||
/// default Whale and System themes pass through unchanged so this whole
|
||||
/// stage compiles down to a single load+compare on the hot path.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn theme_remap_active(theme: ThemeId) -> bool {
|
||||
matches!(
|
||||
theme,
|
||||
ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark
|
||||
)
|
||||
}
|
||||
|
||||
/// Remap a foreground color for a community theme preset. Mirrors the
|
||||
/// structure of [`adapt_fg_for_palette_mode`] — same source set, different
|
||||
/// destinations sourced from the preset's [`UiTheme`].
|
||||
#[must_use]
|
||||
pub fn adapt_fg_for_theme(color: Color, theme: ThemeId) -> Color {
|
||||
if !theme_remap_active(theme) {
|
||||
return color;
|
||||
}
|
||||
let ui = theme.ui_theme();
|
||||
|
||||
if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
|
||||
ui.text_body
|
||||
} else if color == TEXT_SECONDARY || color == TEXT_MUTED {
|
||||
ui.text_muted
|
||||
} else if color == TEXT_HINT || color == TEXT_DIM {
|
||||
ui.text_hint
|
||||
} else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
|
||||
ui.text_soft
|
||||
} else if color == BORDER_COLOR {
|
||||
ui.border
|
||||
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
|
||||
ui.status_working
|
||||
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
|
||||
ui.mode_plan
|
||||
} else if color == ACCENT_TOOL_ISSUE {
|
||||
ui.mode_yolo
|
||||
} else if color == STATUS_WARNING {
|
||||
ui.status_warning
|
||||
} else if color == DEEPSEEK_RED {
|
||||
theme_red(theme)
|
||||
} else if color == DIFF_ADDED || color == USER_BODY {
|
||||
theme_green(theme)
|
||||
} else if color == DEEPSEEK_BLUE {
|
||||
// The default mode_agent accent — keep it in the preset's blue family.
|
||||
ui.mode_agent
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap a background color for a community theme preset.
|
||||
#[must_use]
|
||||
pub fn adapt_bg_for_theme(color: Color, theme: ThemeId) -> Color {
|
||||
if !theme_remap_active(theme) {
|
||||
return color;
|
||||
}
|
||||
let ui = theme.ui_theme();
|
||||
|
||||
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
|
||||
ui.surface_bg
|
||||
} else if color == DEEPSEEK_SLATE
|
||||
|| color == COMPOSER_BG
|
||||
|| color == SURFACE_PANEL
|
||||
|| color == SURFACE_TOOL
|
||||
{
|
||||
ui.panel_bg
|
||||
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
|
||||
ui.elevated_bg
|
||||
} else if color == SURFACE_REASONING
|
||||
|| color == SURFACE_REASONING_TINT
|
||||
|| color == SURFACE_REASONING_ACTIVE
|
||||
|| color == SURFACE_SUCCESS
|
||||
|| color == SURFACE_ERROR
|
||||
{
|
||||
// Reasoning/success/error backgrounds are subtle tints that don't have
|
||||
// a dedicated theme slot — collapse them onto `panel_bg` so they
|
||||
// read as a recessed surface rather than a stray DeepSeek-blue tint.
|
||||
ui.panel_bg
|
||||
} else if color == SELECTION_BG {
|
||||
ui.selection_bg
|
||||
} else if color == DIFF_ADDED_BG {
|
||||
theme_diff_added_bg(theme)
|
||||
} else if color == DIFF_DELETED_BG {
|
||||
theme_diff_deleted_bg(theme)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
// === Color depth + brightness helpers (v0.6.6 UI redesign) ===
|
||||
|
||||
/// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg
|
||||
|
||||
@@ -195,6 +195,14 @@ pub struct Settings {
|
||||
pub show_tool_details: bool,
|
||||
/// UI locale: auto, en, ja, zh-Hans, pt-BR
|
||||
pub locale: String,
|
||||
/// Named UI theme. Accepts `"system"` (follow terminal background),
|
||||
/// the legacy `"dark"` / `"light"` aliases, or one of the community
|
||||
/// presets: `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`,
|
||||
/// `"gruvbox-dark"`. Resolved at startup via
|
||||
/// `palette::ThemeId::from_name`; unknown values fall back to `"system"`
|
||||
/// with a warning. The `background_color` setting still overrides the
|
||||
/// surface color *on top of* the resolved theme.
|
||||
pub theme: String,
|
||||
/// Optional main TUI background color as a 6-digit hex RGB value.
|
||||
pub background_color: Option<String>,
|
||||
/// Composer layout density: compact, comfortable, spacious
|
||||
@@ -287,6 +295,7 @@ impl Default for Settings {
|
||||
show_thinking: true,
|
||||
show_tool_details: true,
|
||||
locale: "auto".to_string(),
|
||||
theme: "system".to_string(),
|
||||
background_color: None,
|
||||
composer_density: "comfortable".to_string(),
|
||||
composer_border: true,
|
||||
@@ -351,6 +360,19 @@ impl Settings {
|
||||
.unwrap_or("en")
|
||||
.to_string();
|
||||
s.background_color = normalize_optional_background_color(s.background_color.as_deref());
|
||||
// Drop unknown theme names so a stale settings file with a typo
|
||||
// doesn't pin the app to whatever the default happens to be.
|
||||
// `from_name` is intentionally permissive about case + aliases.
|
||||
if crate::palette::ThemeId::from_name(&s.theme).is_none() {
|
||||
s.theme = "system".to_string();
|
||||
} else {
|
||||
s.theme = s.theme.trim().to_ascii_lowercase();
|
||||
// Canonicalize aliases (e.g. "whale" -> "dark") so the saved
|
||||
// form matches the picker's stored value.
|
||||
if let Some(id) = crate::palette::ThemeId::from_name(&s.theme) {
|
||||
s.theme = id.name().to_string();
|
||||
}
|
||||
}
|
||||
s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
|
||||
s
|
||||
};
|
||||
@@ -477,6 +499,14 @@ impl Settings {
|
||||
"background_color" | "background" | "bg" => {
|
||||
self.background_color = normalize_background_color_setting(value)?;
|
||||
}
|
||||
"theme" => {
|
||||
let Some(id) = crate::palette::ThemeId::from_name(value) else {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark."
|
||||
);
|
||||
};
|
||||
self.theme = id.name().to_string();
|
||||
}
|
||||
"composer_density" | "composer" => {
|
||||
let normalized = normalize_composer_density(value);
|
||||
if !["compact", "comfortable", "spacious"].contains(&normalized) {
|
||||
@@ -631,6 +661,7 @@ impl Settings {
|
||||
lines.push(format!(" show_thinking: {}", self.show_thinking));
|
||||
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
|
||||
lines.push(format!(" locale: {}", self.locale));
|
||||
lines.push(format!(" theme: {}", self.theme));
|
||||
lines.push(format!(
|
||||
" background_color: {}",
|
||||
self.background_color.as_deref().unwrap_or("(default)")
|
||||
@@ -700,6 +731,10 @@ impl Settings {
|
||||
"locale",
|
||||
"UI locale and default model language: auto, en, ja, zh-Hans, pt-BR",
|
||||
),
|
||||
(
|
||||
"theme",
|
||||
"Named UI theme: system, dark, light, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark",
|
||||
),
|
||||
(
|
||||
"background_color",
|
||||
"Main TUI background color: #RRGGBB or default",
|
||||
|
||||
@@ -862,6 +862,11 @@ pub struct App {
|
||||
/// Animation anchor for status-strip active sub-agent spinner.
|
||||
pub agent_activity_started_at: Option<Instant>,
|
||||
pub ui_theme: UiTheme,
|
||||
/// Active named theme. Drives the cell-level color remap in
|
||||
/// `tui::color_compat::ColorCompatBackend` so community presets
|
||||
/// (Catppuccin, Tokyo Night, Dracula, Gruvbox) propagate to every
|
||||
/// render site, not just the handful that read `app.ui_theme`.
|
||||
pub theme_id: palette::ThemeId,
|
||||
// Onboarding
|
||||
pub onboarding: OnboardingState,
|
||||
pub onboarding_needs_api_key: bool,
|
||||
@@ -1262,7 +1267,12 @@ impl App {
|
||||
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
|
||||
let max_input_history = settings.max_input_history;
|
||||
let use_paste_burst_detection = settings.paste_burst_detection;
|
||||
let mut ui_theme = palette::UiTheme::detect();
|
||||
// Resolve the named theme from settings; unknown values were already
|
||||
// normalised to "system" in Settings::load. The background_color
|
||||
// setting still overlays on top.
|
||||
let theme_id =
|
||||
palette::ThemeId::from_name(&settings.theme).unwrap_or(palette::ThemeId::System);
|
||||
let mut ui_theme = theme_id.ui_theme();
|
||||
if let Some(background) = settings
|
||||
.background_color
|
||||
.as_deref()
|
||||
@@ -1463,6 +1473,7 @@ impl App {
|
||||
pending_subagent_dispatch: None,
|
||||
agent_activity_started_at: None,
|
||||
ui_theme,
|
||||
theme_id,
|
||||
onboarding,
|
||||
onboarding_needs_api_key: needs_api_key,
|
||||
onboarding_workspace_trust_gate,
|
||||
@@ -3995,6 +4006,8 @@ pub enum AppAction {
|
||||
OpenStatusPicker,
|
||||
/// Open the `/feedback` picker for GitHub issue/security destinations.
|
||||
OpenFeedbackPicker,
|
||||
/// Open the `/theme` picker modal with live preview of every preset.
|
||||
OpenThemePicker,
|
||||
/// Open an external URL in the system browser.
|
||||
OpenExternalUrl {
|
||||
url: String,
|
||||
|
||||
@@ -14,13 +14,18 @@ use ratatui::{
|
||||
layout::{Position, Size},
|
||||
};
|
||||
|
||||
use crate::palette::{self, ColorDepth, PaletteMode};
|
||||
use crate::palette::{self, ColorDepth, PaletteMode, ThemeId};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ColorCompatBackend<W: Write> {
|
||||
inner: CrosstermBackend<W>,
|
||||
depth: ColorDepth,
|
||||
palette_mode: PaletteMode,
|
||||
/// Currently active named theme. `System`/`Whale`/`WhaleLight` make the
|
||||
/// theme remap a no-op (those rely on the dark/light pipeline); the
|
||||
/// community presets (Catppuccin, Tokyo Night, Dracula, Gruvbox) trigger
|
||||
/// a per-cell rewrite of dark-palette constants → preset slots.
|
||||
theme_id: ThemeId,
|
||||
/// During a resize event the terminal emulator may report stale dimensions
|
||||
/// for a brief window (observed on macOS Terminal.app and Windows ConHost).
|
||||
/// Forcing the expected size prevents ratatui's internal `autoresize` from
|
||||
@@ -34,6 +39,7 @@ impl<W: Write> ColorCompatBackend<W> {
|
||||
inner: CrosstermBackend::new(writer),
|
||||
depth,
|
||||
palette_mode,
|
||||
theme_id: ThemeId::System,
|
||||
forced_size: None,
|
||||
}
|
||||
}
|
||||
@@ -49,6 +55,10 @@ impl<W: Write> ColorCompatBackend<W> {
|
||||
pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) {
|
||||
self.palette_mode = palette_mode;
|
||||
}
|
||||
|
||||
pub(crate) fn set_theme(&mut self, theme_id: ThemeId) {
|
||||
self.theme_id = theme_id;
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> Write for ColorCompatBackend<W> {
|
||||
@@ -71,7 +81,7 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
|
||||
let adapted = content
|
||||
.map(|(x, y, cell)| {
|
||||
let mut cell = cell.clone();
|
||||
adapt_cell_colors(&mut cell, self.depth, self.palette_mode);
|
||||
adapt_cell_colors(&mut cell, self.depth, self.palette_mode, self.theme_id);
|
||||
(x, y, cell)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -123,10 +133,24 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
|
||||
}
|
||||
}
|
||||
|
||||
fn adapt_cell_colors(cell: &mut Cell, depth: ColorDepth, palette_mode: PaletteMode) {
|
||||
fn adapt_cell_colors(
|
||||
cell: &mut Cell,
|
||||
depth: ColorDepth,
|
||||
palette_mode: PaletteMode,
|
||||
theme_id: ThemeId,
|
||||
) {
|
||||
// Stage 1: community-theme remap (dark palette → preset slots). No-op
|
||||
// for System / Whale / WhaleLight so legacy dark/light flows are
|
||||
// untouched. Runs *before* the palette-mode remap so a light terminal
|
||||
// running e.g. Catppuccin still routes the preset colors through the
|
||||
// light adaptation below (rare combo, but the sequencing is the same).
|
||||
cell.fg = palette::adapt_fg_for_theme(cell.fg, theme_id);
|
||||
cell.bg = palette::adapt_bg_for_theme(cell.bg, theme_id);
|
||||
// Stage 2: legacy dark↔light remap.
|
||||
let original_bg = cell.bg;
|
||||
cell.fg = palette::adapt_fg_for_palette_mode(cell.fg, original_bg, palette_mode);
|
||||
cell.bg = palette::adapt_bg_for_palette_mode(cell.bg, palette_mode);
|
||||
// Stage 3: depth (truecolor / 256 / 16) downsampling.
|
||||
cell.fg = palette::adapt_color(cell.fg, depth);
|
||||
cell.bg = palette::adapt_bg(cell.bg, depth);
|
||||
}
|
||||
@@ -160,7 +184,12 @@ mod tests {
|
||||
cell.set_fg(Color::Rgb(53, 120, 229));
|
||||
cell.set_bg(Color::Rgb(11, 21, 38));
|
||||
|
||||
adapt_cell_colors(&mut cell, ColorDepth::Ansi256, PaletteMode::Dark);
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
ColorDepth::Ansi256,
|
||||
PaletteMode::Dark,
|
||||
ThemeId::System,
|
||||
);
|
||||
|
||||
assert!(matches!(cell.fg, Color::Indexed(_)));
|
||||
assert!(matches!(cell.bg, Color::Indexed(_)));
|
||||
@@ -172,7 +201,12 @@ mod tests {
|
||||
cell.set_fg(Color::Rgb(53, 120, 229));
|
||||
cell.set_bg(Color::Rgb(11, 21, 38));
|
||||
|
||||
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Dark);
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
ColorDepth::TrueColor,
|
||||
PaletteMode::Dark,
|
||||
ThemeId::System,
|
||||
);
|
||||
|
||||
assert_eq!(cell.fg, Color::Rgb(53, 120, 229));
|
||||
assert_eq!(cell.bg, Color::Rgb(11, 21, 38));
|
||||
@@ -201,7 +235,12 @@ mod tests {
|
||||
cell.set_fg(Color::White);
|
||||
cell.set_bg(Color::Rgb(11, 21, 38));
|
||||
|
||||
adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Light);
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
ColorDepth::TrueColor,
|
||||
PaletteMode::Light,
|
||||
ThemeId::WhaleLight,
|
||||
);
|
||||
|
||||
assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY);
|
||||
assert_eq!(cell.bg, palette::LIGHT_SURFACE);
|
||||
|
||||
@@ -52,6 +52,7 @@ pub mod sidebar;
|
||||
pub mod slash_menu;
|
||||
pub mod streaming;
|
||||
mod subagent_routing;
|
||||
pub mod theme_picker;
|
||||
mod tool_routing;
|
||||
pub mod transcript;
|
||||
pub mod transcript_cache;
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! `/theme` picker with live preview.
|
||||
//!
|
||||
//! Modeled after `feedback_picker`. Differences:
|
||||
//! - The option list comes from `palette::SELECTABLE_THEMES`.
|
||||
//! - Up/Down emit a `ConfigUpdated{persist:false}` so the host swaps
|
||||
//! `app.ui_theme` immediately and the whole TUI re-paints under the
|
||||
//! modal — the user sees the candidate theme before committing.
|
||||
//! - Enter persists (`persist:true`); Esc emits one more
|
||||
//! `ConfigUpdated{persist:false}` to restore the original theme name
|
||||
//! that was active when the picker opened.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::palette::{SELECTABLE_THEMES, ThemeId};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
|
||||
pub struct ThemePickerView {
|
||||
selected: usize,
|
||||
/// Settings name of the theme that was active when the picker opened.
|
||||
/// Used to revert on Esc.
|
||||
original_name: String,
|
||||
}
|
||||
|
||||
impl ThemePickerView {
|
||||
#[must_use]
|
||||
pub fn new(original_name: String) -> Self {
|
||||
// If the persisted name matches one of the entries, start there;
|
||||
// otherwise fall back to "System" so the cursor lands on a valid row.
|
||||
let selected = SELECTABLE_THEMES
|
||||
.iter()
|
||||
.position(|id| id.name() == original_name.trim().to_ascii_lowercase())
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
selected,
|
||||
original_name,
|
||||
}
|
||||
}
|
||||
|
||||
fn current(&self) -> ThemeId {
|
||||
SELECTABLE_THEMES
|
||||
.get(self.selected)
|
||||
.copied()
|
||||
.unwrap_or(ThemeId::System)
|
||||
}
|
||||
|
||||
fn preview_event(&self) -> ViewAction {
|
||||
ViewAction::Emit(ViewEvent::ConfigUpdated {
|
||||
key: "theme".to_string(),
|
||||
value: self.current().name().to_string(),
|
||||
persist: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_event(&self) -> ViewAction {
|
||||
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
|
||||
key: "theme".to_string(),
|
||||
value: self.current().name().to_string(),
|
||||
persist: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn revert_event(&self) -> ViewAction {
|
||||
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
|
||||
key: "theme".to_string(),
|
||||
value: self.original_name.clone(),
|
||||
persist: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let max = SELECTABLE_THEMES.len().saturating_sub(1);
|
||||
if self.selected < max {
|
||||
self.selected += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for ThemePickerView {
|
||||
fn kind(&self) -> ModalKind {
|
||||
ModalKind::ThemePicker
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => self.revert_event(),
|
||||
KeyCode::Enter => self.commit_event(),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.move_up();
|
||||
self.preview_event()
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.move_down();
|
||||
self.preview_event()
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.selected = 0;
|
||||
self.preview_event()
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.selected = SELECTABLE_THEMES.len().saturating_sub(1);
|
||||
self.preview_event()
|
||||
}
|
||||
// Number shortcuts: 1..=9 → jump to that row (1-indexed).
|
||||
KeyCode::Char(c)
|
||||
if c.is_ascii_digit()
|
||||
&& !key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
let idx = (c as usize).saturating_sub('1' as usize);
|
||||
if idx < SELECTABLE_THEMES.len() {
|
||||
self.selected = idx;
|
||||
self.preview_event()
|
||||
} else {
|
||||
ViewAction::None
|
||||
}
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 78.min(area.width.saturating_sub(4)).max(52);
|
||||
// 1 title row + 1 spacer + N options + 2 spacer + 1 swatch row
|
||||
let needed_height = (SELECTABLE_THEMES.len() as u16).saturating_add(9);
|
||||
let popup_height = needed_height.min(area.height.saturating_sub(4)).max(10);
|
||||
|
||||
let popup_area = Rect {
|
||||
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
|
||||
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
|
||||
width: popup_width,
|
||||
height: popup_height,
|
||||
};
|
||||
|
||||
// The live theme has already been swapped under us via ConfigUpdated,
|
||||
// so we pull the *current* preview's UiTheme from the cursor row to
|
||||
// skin the modal chrome. That way the popup itself shifts color as
|
||||
// the cursor moves, matching what the background will look like
|
||||
// after Enter.
|
||||
let live = self.current().ui_theme();
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
" Theme ",
|
||||
Style::default()
|
||||
.fg(live.status_working)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.title_bottom(Line::from(vec![
|
||||
Span::styled(" ↑/↓ ", Style::default().fg(live.text_muted)),
|
||||
Span::raw("preview "),
|
||||
Span::styled(" Enter ", Style::default().fg(live.text_muted)),
|
||||
Span::raw("save "),
|
||||
Span::styled(" Esc ", Style::default().fg(live.text_muted)),
|
||||
Span::raw("revert "),
|
||||
]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(live.border))
|
||||
.style(Style::default().bg(live.surface_bg))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(SELECTABLE_THEMES.len() + 5);
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Pick a theme — preview is live; Enter saves to settings.toml.",
|
||||
Style::default().fg(live.text_muted),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (idx, id) in SELECTABLE_THEMES.iter().enumerate() {
|
||||
let id = *id;
|
||||
let is_selected = idx == self.selected;
|
||||
let row_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(live.text_body)
|
||||
.bg(live.selection_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(live.text_body)
|
||||
};
|
||||
let tagline_style = if is_selected {
|
||||
Style::default().fg(live.text_muted).bg(live.selection_bg)
|
||||
} else {
|
||||
Style::default().fg(live.text_dim)
|
||||
};
|
||||
let number_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(live.status_working)
|
||||
.bg(live.selection_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(live.text_hint)
|
||||
};
|
||||
let pointer = if is_selected { "▶" } else { " " };
|
||||
|
||||
// 3-cell color swatch per row using the candidate theme's own
|
||||
// accent + panel + border colors so the picker doubles as a
|
||||
// legend.
|
||||
let row_theme = id.ui_theme();
|
||||
let swatch = vec![
|
||||
Span::styled(" ", Style::default().bg(row_theme.surface_bg)),
|
||||
Span::styled(" ", Style::default().bg(row_theme.panel_bg)),
|
||||
Span::styled(" ", Style::default().bg(row_theme.status_working)),
|
||||
Span::styled(" ", Style::default().bg(row_theme.mode_yolo)),
|
||||
Span::styled(" ", Style::default().bg(row_theme.mode_plan)),
|
||||
];
|
||||
|
||||
let mut spans: Vec<Span> = Vec::with_capacity(8);
|
||||
spans.push(Span::styled(format!(" {pointer} "), row_style));
|
||||
spans.push(Span::styled(format!("{}. ", idx + 1), number_style));
|
||||
spans.push(Span::styled(
|
||||
format!("{:<22}", id.display_name()),
|
||||
row_style,
|
||||
));
|
||||
spans.extend(swatch);
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(id.tagline(), tagline_style));
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn selected_name(action: &ViewAction) -> Option<&str> {
|
||||
match action {
|
||||
ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. })
|
||||
| ViewAction::EmitAndClose(ViewEvent::ConfigUpdated { key, value, .. })
|
||||
if key == "theme" =>
|
||||
{
|
||||
Some(value.as_str())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_at_persisted_theme() {
|
||||
let v = ThemePickerView::new("tokyo-night".to_string());
|
||||
assert_eq!(v.current(), ThemeId::TokyoNight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_persisted_name_falls_back_to_first_row() {
|
||||
let v = ThemePickerView::new("not-a-real-theme".to_string());
|
||||
assert_eq!(v.selected, 0);
|
||||
assert_eq!(v.current(), ThemeId::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_down_previews_next_theme() {
|
||||
let mut v = ThemePickerView::new("system".to_string());
|
||||
let action = v.handle_key(key(KeyCode::Down));
|
||||
assert!(matches!(action, ViewAction::Emit(_)));
|
||||
assert_eq!(selected_name(&action), Some(ThemeId::Whale.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_commits_with_persist_true() {
|
||||
let mut v = ThemePickerView::new("system".to_string());
|
||||
v.handle_key(key(KeyCode::Down));
|
||||
v.handle_key(key(KeyCode::Down));
|
||||
v.handle_key(key(KeyCode::Down)); // -> CatppuccinMocha
|
||||
let action = v.handle_key(key(KeyCode::Enter));
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
|
||||
key,
|
||||
value,
|
||||
persist,
|
||||
}) => {
|
||||
assert_eq!(key, "theme");
|
||||
assert_eq!(value, ThemeId::CatppuccinMocha.name());
|
||||
assert!(persist);
|
||||
}
|
||||
other => panic!("expected commit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_reverts_to_original() {
|
||||
let mut v = ThemePickerView::new("dracula".to_string());
|
||||
v.handle_key(key(KeyCode::Up));
|
||||
v.handle_key(key(KeyCode::Up));
|
||||
let action = v.handle_key(key(KeyCode::Esc));
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::ConfigUpdated {
|
||||
key,
|
||||
value,
|
||||
persist,
|
||||
}) => {
|
||||
assert_eq!(key, "theme");
|
||||
assert_eq!(value, "dracula");
|
||||
assert!(!persist);
|
||||
}
|
||||
other => panic!("expected revert, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digit_jumps_to_row() {
|
||||
let mut v = ThemePickerView::new("system".to_string());
|
||||
let action = v.handle_key(key(KeyCode::Char('4')));
|
||||
// Row 4 (1-indexed) → index 3 → CatppuccinMocha
|
||||
assert_eq!(
|
||||
selected_name(&action),
|
||||
Some(ThemeId::CatppuccinMocha.name())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5270,6 +5270,19 @@ async fn apply_command_result(
|
||||
.push(crate::tui::feedback_picker::FeedbackPickerView::new());
|
||||
}
|
||||
}
|
||||
AppAction::OpenThemePicker => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
|
||||
// Capture the persisted theme name so Esc can revert
|
||||
// through the same ConfigUpdated channel. Falling back
|
||||
// to "system" when Settings::load fails matches the
|
||||
// app-startup default.
|
||||
let original = crate::settings::Settings::load()
|
||||
.map(|s| s.theme)
|
||||
.unwrap_or_else(|_| "system".to_string());
|
||||
app.view_stack
|
||||
.push(crate::tui::theme_picker::ThemePickerView::new(original));
|
||||
}
|
||||
}
|
||||
AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) {
|
||||
Ok(()) => {
|
||||
app.status_message = Some(format!("Opened {label} in your browser"));
|
||||
@@ -6167,6 +6180,7 @@ fn draw_app_frame_inner(
|
||||
full_repaint: bool,
|
||||
) -> Result<()> {
|
||||
terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
|
||||
terminal.backend_mut().set_theme(app.theme_id);
|
||||
// DEC 2026 wrapping is on by default but can be turned off for
|
||||
// terminals that mishandle it (Ptyxis 50.x + VTE 0.84.x flashes the
|
||||
// whole viewport on every wrapped frame instead of deferring as the
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum ModalKind {
|
||||
FilePicker,
|
||||
StatusPicker,
|
||||
FeedbackPicker,
|
||||
ThemePicker,
|
||||
ContextMenu,
|
||||
ShellControl,
|
||||
}
|
||||
|
||||
@@ -672,6 +672,12 @@ mod tests {
|
||||
// file, which overrides the option above. Pin the model explicitly
|
||||
// so these tests are independent of any host-side configuration.
|
||||
app.model = "deepseek-v4-flash".to_string();
|
||||
// Same for theme: tests below assert against the default `MODE_AGENT`
|
||||
// / `TEXT_MUTED` constants, but a saved `theme = "tokyo-night"` in
|
||||
// the user's settings.toml would override them through the
|
||||
// theme-aware constructor. Pin to System (no remap) for isolation.
|
||||
app.theme_id = crate::palette::ThemeId::System;
|
||||
app.ui_theme = crate::palette::UiTheme::detect();
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user