aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 02:52:35 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 02:53:31 +0300
commit36b4001f4526b60f1c9b67a4a34596e5ad2bcca1 (patch)
tree520ab28a0a3f3f798e6e655fc83529e8617720e9 /src/app
parentaca35ea76f3df15ec68fd07959d939f5e7a78a6c (diff)
feat: sync theme with host appearance
refs #675
Diffstat (limited to 'src/app')
-rw-r--r--src/app/config_io.rs8
-rw-r--r--src/app/input/settings.rs8
-rw-r--r--src/app/mod.rs213
-rw-r--r--src/app/runtime.rs4
-rw-r--r--src/app/state.rs30
-rw-r--r--src/app/theme_sync.rs60
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);
}