diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-18 02:52:35 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-18 02:53:31 +0300 |
| commit | 36b4001f4526b60f1c9b67a4a34596e5ad2bcca1 (patch) | |
| tree | 520ab28a0a3f3f798e6e655fc83529e8617720e9 /src/app | |
| parent | aca35ea76f3df15ec68fd07959d939f5e7a78a6c (diff) | |
feat: sync theme with host appearance
refs #675
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/config_io.rs | 8 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 8 | ||||
| -rw-r--r-- | src/app/mod.rs | 213 | ||||
| -rw-r--r-- | src/app/runtime.rs | 4 | ||||
| -rw-r--r-- | src/app/state.rs | 30 | ||||
| -rw-r--r-- | src/app/theme_sync.rs | 60 |
6 files changed, 278 insertions, 45 deletions
diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 7a196008..95014b30 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -43,7 +43,13 @@ impl App { pub(super) fn save_theme(&mut self, name: &str) { if self.update_config_file("theme", |content| { - crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) + let content = crate::config::upsert_section_value( + content, + "theme", + "name", + &format!("\"{name}\""), + ); + crate::config::upsert_section_bool(&content, "theme", "auto_switch", false) }) { self.apply_config_from_disk(false); } diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index cc4f6d58..9651b075 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -100,7 +100,13 @@ fn preview_selected_theme(state: &mut AppState) { use crate::app::state::Palette; let name = THEME_NAMES[state.settings.list.selected]; - if let Some(palette) = Palette::from_name(name) { + if let Some(mut palette) = Palette::from_name(name) { + if let Some(custom) = &state.theme_runtime.custom { + palette = palette.with_overrides(custom); + } + if let Some(accent) = &state.theme_runtime.legacy_accent { + palette.accent = crate::config::parse_color(accent); + } state.palette = palette; state.theme_name = name.to_string(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index ff1581cf..c8e3eb55 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -228,46 +228,109 @@ fn parse_cjk_ime_agents(names: &[String]) -> Vec<crate::detect::Agent> { out } -/// Resolve the palette from config: base theme + optional custom overrides. -fn resolve_palette(config: &crate::config::Config) -> state::Palette { - resolve_palette_with_legacy_accent(config, true) +fn normalize_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") } -fn resolve_palette_with_legacy_accent( +fn sibling_theme_names(name: &str) -> (String, String) { + match normalize_theme_name(name).as_str() { + "catppuccin" | "catppuccin-mocha" | "catppuccin-latte" | "latte" | "light" => { + ("catppuccin".to_string(), "catppuccin-latte".to_string()) + } + "tokyo-night" | "tokyonight" | "tokyo-night-day" | "tokyo-day" | "tokyonight-day" => { + ("tokyo-night".to_string(), "tokyo-night-day".to_string()) + } + "gruvbox" | "gruvbox-dark" | "gruvbox-light" => { + ("gruvbox".to_string(), "gruvbox-light".to_string()) + } + "one-dark" | "onedark" | "one-light" | "onelight" => { + ("one-dark".to_string(), "one-light".to_string()) + } + "solarized" | "solarized-dark" | "solarized-light" => { + ("solarized".to_string(), "solarized-light".to_string()) + } + "kanagawa" | "kanagawa-lotus" | "lotus" => { + ("kanagawa".to_string(), "kanagawa-lotus".to_string()) + } + "rose-pine" | "rosepine" | "rose-pine-dawn" | "rosepine-dawn" | "dawn" => { + ("rose-pine".to_string(), "rose-pine-dawn".to_string()) + } + _ => (name.to_string(), name.to_string()), + } +} + +fn theme_runtime_config( config: &crate::config::Config, use_legacy_ui_accent: bool, +) -> state::ThemeRuntimeConfig { + let manual_name = config + .theme + .name + .clone() + .unwrap_or_else(|| "catppuccin".to_string()); + let (default_dark, default_light) = sibling_theme_names(&manual_name); + state::ThemeRuntimeConfig { + manual_name, + dark_name: config.theme.dark_name.clone().unwrap_or(default_dark), + light_name: config.theme.light_name.clone().unwrap_or(default_light), + auto_switch: config.theme.auto_switch, + custom: config.theme.custom.clone(), + legacy_accent: (use_legacy_ui_accent + && config.ui.accent != "cyan" + && config + .theme + .custom + .as_ref() + .and_then(|c| c.accent.as_ref()) + .is_none()) + .then(|| config.ui.accent.clone()), + } +} + +fn resolve_palette_for_theme_name( + name: &str, + fallback_name: &str, + runtime: &state::ThemeRuntimeConfig, ) -> state::Palette { - // Start with the named theme (default: catppuccin) - let base_name = config.theme.name.as_deref().unwrap_or("catppuccin"); - let mut palette = state::Palette::from_name(base_name).unwrap_or_else(|| { + let mut palette = state::Palette::from_name(name).unwrap_or_else(|| { tracing::warn!( - theme = base_name, - "unknown theme, falling back to catppuccin" + theme = name, + fallback = fallback_name, + "unknown theme, falling back" ); - state::Palette::catppuccin() + state::Palette::from_name(fallback_name).unwrap_or_else(state::Palette::catppuccin) }); - // Apply custom overrides if present - if let Some(custom) = &config.theme.custom { + if let Some(custom) = &runtime.custom { palette = palette.with_overrides(custom); } - - // Legacy: if ui.accent is set and no theme.custom.accent, use it for compat - if use_legacy_ui_accent - && config.ui.accent != "cyan" - && config - .theme - .custom - .as_ref() - .and_then(|c| c.accent.as_ref()) - .is_none() - { - palette.accent = crate::config::parse_color(&config.ui.accent); + if let Some(accent) = &runtime.legacy_accent { + palette.accent = crate::config::parse_color(accent); } palette } +fn resolve_effective_theme( + runtime: &state::ThemeRuntimeConfig, + appearance: Option<crate::terminal_theme::HostAppearance>, +) -> (state::Palette, String) { + let (name, fallback) = if runtime.auto_switch { + match appearance.unwrap_or(crate::terminal_theme::HostAppearance::Dark) { + crate::terminal_theme::HostAppearance::Dark => (&runtime.dark_name, "catppuccin"), + crate::terminal_theme::HostAppearance::Light => { + (&runtime.light_name, "catppuccin-latte") + } + } + } else { + (&runtime.manual_name, "catppuccin") + }; + ( + resolve_palette_for_theme_name(name, fallback, runtime), + name.clone(), + ) +} + impl App { pub fn new( config: &Config, @@ -416,6 +479,8 @@ impl App { }; let agent_manifest_summaries = crate::detect::manifest::reload_manifests(); + let theme_runtime = theme_runtime_config(config, true); + let (theme_palette, theme_name) = resolve_effective_theme(&theme_runtime, None); let mut state = AppState { terminals: std::collections::HashMap::new(), @@ -543,12 +608,11 @@ impl App { toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), spinner_tick: 0, - palette: resolve_palette(config), - theme_name: config - .theme - .name - .clone() - .unwrap_or_else(|| "catppuccin".to_string()), + palette: theme_palette, + theme_name, + theme_runtime, + host_terminal_appearance: None, + host_terminal_appearance_explicit: false, settings: state::SettingsState { section: state::SettingsSection::Theme, list: state::SelectionListState::new(0), @@ -1292,12 +1356,8 @@ impl App { } if !invalid_section("theme") { - self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui")); - self.state.theme_name = config - .theme - .name - .clone() - .unwrap_or_else(|| "catppuccin".to_string()); + self.state.theme_runtime = theme_runtime_config(config, !invalid_section("ui")); + self.refresh_effective_app_theme(); } let status = if diagnostics.is_empty() { @@ -1435,6 +1495,11 @@ impl App { self.update_host_terminal_theme(kind, color); } } + crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { + if apply_host_terminal_theme { + self.set_host_terminal_appearance(appearance, true); + } + } crate::raw_input::RawInputEvent::Unsupported => {} } self.sync_prefix_input_source(previous_mode); @@ -2082,6 +2147,82 @@ mod tests { } #[test] + fn theme_auto_switch_is_opt_in_and_preserves_manual_default() { + let mut config = Config::default(); + config.theme.name = Some("tokyo-night".to_string()); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + + let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + assert!(!app.state.theme_runtime.auto_switch); + assert_eq!(app.state.theme_name, "tokyo-night"); + assert_eq!(app.state.palette, state::Palette::tokyo_night()); + } + + #[test] + fn theme_auto_switch_uses_sibling_map_and_explicit_appearance() { + let mut config = Config::default(); + config.theme.name = Some("tokyo-night".to_string()); + config.theme.auto_switch = true; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + assert_eq!(app.state.theme_name, "tokyo-night"); + assert!( + app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true) + ); + + assert_eq!(app.state.theme_name, "tokyo-night-day"); + assert_eq!(app.state.palette, state::Palette::tokyo_night_day()); + } + + #[test] + fn theme_auto_switch_applies_custom_overrides_after_active_base() { + let mut config = Config::default(); + config.theme.name = Some("gruvbox".to_string()); + config.theme.auto_switch = true; + config.theme.custom = Some(crate::config::CustomThemeColors { + accent: Some("#010203".to_string()), + ..Default::default() + }); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + + assert_eq!(app.state.theme_name, "gruvbox-light"); + assert_eq!( + app.state.palette.accent, + ratatui::style::Color::Rgb(1, 2, 3) + ); + } + + #[test] + fn inferred_background_appearance_does_not_override_explicit_report() { + let mut config = Config::default(); + config.theme.name = Some("catppuccin".to_string()); + config.theme.auto_switch = true; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true); + app.update_host_terminal_theme( + crate::terminal_theme::DefaultColorKind::Background, + crate::terminal_theme::RgbColor { + r: 0xff, + g: 0xff, + b: 0xff, + }, + ); + + assert_eq!( + app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert_eq!(app.state.theme_name, "catppuccin"); + } + + #[test] fn startup_restores_preview_update_available_from_saved_notes() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-preview-update-available"); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 815701b7..097f1ace 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -159,6 +159,10 @@ impl App { crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { self.update_host_terminal_theme(kind, color) } + crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { + self.query_host_terminal_theme(); + self.set_host_terminal_appearance(appearance, true) + } crate::raw_input::RawInputEvent::Unsupported => false, }; self.sync_prefix_input_source(previous_mode); diff --git a/src/app/state.rs b/src/app/state.rs index 585bb3e4..55d41358 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -46,7 +46,7 @@ pub(crate) struct RightClickPassthroughGesture { pub pane_info: PaneInfo, pub modifiers: KeyModifiers, } -use crate::terminal_theme::TerminalTheme; +use crate::terminal_theme::{HostAppearance, TerminalTheme}; use crate::workspace::Workspace; // --------------------------------------------------------------------------- @@ -55,7 +55,7 @@ use crate::workspace::Workspace; /// All colors used by the UI. Derived from a base accent color for now, /// but structured so a full theme system can replace it later. -#[derive(Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] // all fields defined for theming — some used later pub struct Palette { /// Primary accent (highlight, active borders). @@ -978,6 +978,16 @@ impl SelectionListState { } } +#[derive(Debug, Clone)] +pub struct ThemeRuntimeConfig { + pub manual_name: String, + pub dark_name: String, + pub light_name: String, + pub auto_switch: bool, + pub custom: Option<crate::config::CustomThemeColors>, + pub legacy_accent: Option<String>, +} + pub struct SettingsState { /// Which section tab is active. pub section: SettingsSection, @@ -1384,6 +1394,12 @@ pub struct AppState { pub palette: Palette, /// Currently applied theme name (for settings UI). pub theme_name: String, + /// Runtime theme configuration used to resolve manual and auto-switch palettes. + pub theme_runtime: ThemeRuntimeConfig, + /// Last known foreground host terminal appearance. + pub host_terminal_appearance: Option<HostAppearance>, + /// True when the foreground host explicitly reported appearance via Mode 2031. + pub host_terminal_appearance_explicit: bool, /// Settings panel state. pub settings: SettingsState, /// Cached integration recommendations for onboarding/settings UI. @@ -1722,6 +1738,16 @@ impl AppState { spinner_tick: 0, palette: Palette::catppuccin(), theme_name: "catppuccin".to_string(), + theme_runtime: ThemeRuntimeConfig { + manual_name: "catppuccin".to_string(), + dark_name: "catppuccin".to_string(), + light_name: "catppuccin-latte".to_string(), + auto_switch: false, + custom: None, + legacy_accent: None, + }, + host_terminal_appearance: None, + host_terminal_appearance_explicit: false, settings: SettingsState { section: SettingsSection::Theme, list: SelectionListState::new(0), diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index a45818df..392d6f59 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -16,15 +16,54 @@ impl App { kind: crate::terminal_theme::DefaultColorKind, color: crate::terminal_theme::RgbColor, ) -> bool { + let mut changed = false; + if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) + && !self.state.host_terminal_appearance_explicit + { + changed |= self.set_host_terminal_appearance(color.inferred_appearance(), false); + } let next_theme = self.state.host_terminal_theme.with_color(kind, color); - self.set_host_terminal_theme(next_theme) + changed | self.set_host_terminal_theme(next_theme) + } + + pub(super) fn set_host_terminal_appearance( + &mut self, + appearance: crate::terminal_theme::HostAppearance, + explicit: bool, + ) -> bool { + if self.state.host_terminal_appearance == Some(appearance) + && self.state.host_terminal_appearance_explicit == explicit + { + return false; + } + if self.state.host_terminal_appearance_explicit && !explicit { + return false; + } + self.state.host_terminal_appearance = Some(appearance); + self.state.host_terminal_appearance_explicit = explicit; + self.refresh_effective_app_theme() + } + + pub(crate) fn set_host_terminal_appearance_state( + &mut self, + appearance: Option<crate::terminal_theme::HostAppearance>, + explicit: bool, + ) -> bool { + if self.state.host_terminal_appearance == appearance + && self.state.host_terminal_appearance_explicit == explicit + { + return false; + } + self.state.host_terminal_appearance = appearance; + self.state.host_terminal_appearance_explicit = explicit; + self.refresh_effective_app_theme() } pub(crate) fn set_host_terminal_theme( &mut self, theme: crate::terminal_theme::TerminalTheme, ) -> bool { - if theme.is_empty() || theme == self.state.host_terminal_theme { + if theme == self.state.host_terminal_theme { return false; } self.state.host_terminal_theme = theme; @@ -32,11 +71,22 @@ impl App { true } - fn apply_host_terminal_theme_to_panes(&self) { - if self.state.host_terminal_theme.is_empty() { - return; + pub(super) fn refresh_effective_app_theme(&mut self) -> bool { + let (palette, theme_name) = super::resolve_effective_theme( + &self.state.theme_runtime, + self.state.host_terminal_appearance, + ); + if self.state.theme_name == theme_name && self.state.palette == palette { + return false; } + self.state.theme_name = theme_name; + self.state.palette = palette; + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + true + } + fn apply_host_terminal_theme_to_panes(&self) { for runtime in self.terminal_runtimes.values() { runtime.apply_host_terminal_theme(self.state.host_terminal_theme); } |
