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 | |
| parent | aca35ea76f3df15ec68fd07959d939f5e7a78a6c (diff) | |
feat: sync theme with host appearance
refs #675
Diffstat (limited to 'src')
| -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 | ||||
| -rw-r--r-- | src/client/input.rs | 17 | ||||
| -rw-r--r-- | src/client/mod.rs | 112 | ||||
| -rw-r--r-- | src/config/theme.rs | 29 | ||||
| -rw-r--r-- | src/main.rs | 21 | ||||
| -rw-r--r-- | src/pane/osc.rs | 38 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 88 | ||||
| -rw-r--r-- | src/raw_input.rs | 213 | ||||
| -rw-r--r-- | src/server/clients.rs | 48 | ||||
| -rw-r--r-- | src/server/headless.rs | 154 | ||||
| -rw-r--r-- | src/terminal_theme.rs | 38 |
16 files changed, 998 insertions, 83 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); } diff --git a/src/client/input.rs b/src/client/input.rs index add4990c..ba1e353f 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -59,6 +59,7 @@ fn unix_stdin_reader_loop( let mut framer = crate::raw_input::RawInputByteFramer::default(); if host_color_query_sent { framer.host_color_query_sent(); + framer.enable_host_color_scheme_change_tracking(); } while !should_quit.load(Ordering::Acquire) { @@ -75,7 +76,10 @@ fn unix_stdin_reader_loop( } if stdin_read_ready(&reader, 10) == Some(false) { - for data in framer.flush_timeout() { + let had_pending = framer.has_pending_input(); + let chunks = framer.flush_timeout(); + let held_escape = had_pending && chunks.is_empty(); + for data in chunks { if event_tx .blocking_send(ClientLoopEvent::StdinInput(data)) .is_err() @@ -83,6 +87,16 @@ fn unix_stdin_reader_loop( return; } } + if held_escape && stdin_read_ready(&reader, 10) == Some(false) { + for data in framer.flush_timeout() { + if event_tx + .blocking_send(ClientLoopEvent::StdinInput(data)) + .is_err() + { + return; + } + } + } } } Err(err) => { @@ -268,6 +282,7 @@ fn windows_client_input_event_from_raw( Some(crate::protocol::ClientInputEvent::FocusLost) } crate::raw_input::RawInputEvent::HostDefaultColor { .. } + | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) | crate::raw_input::RawInputEvent::Unsupported => None, } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 70d39559..834edd2b 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -329,6 +329,8 @@ fn setup_terminal_with_capabilities( mouse_capture: bool, ) -> io::Result<TerminalGuard> { ratatui::init(); + let host_color_scheme_reports = + should_enable_host_color_scheme_reports(enable_client_protocols); if enable_client_protocols { if mouse_capture { @@ -337,11 +339,19 @@ fn setup_terminal_with_capabilities( set_mouse_capture(false)?; } execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; + if host_color_scheme_reports { + write_host_color_scheme_report_mode(&mut io::stdout(), true)?; + } push_keyboard_enhancement_flags()?; - } else if mouse_capture { - set_mouse_capture(true)?; } else { - set_mouse_capture(false)?; + if should_query_host_terminal_theme() { + write_host_color_scheme_report_mode(&mut io::stdout(), false)?; + } + if mouse_capture { + set_mouse_capture(true)?; + } else { + set_mouse_capture(false)?; + } } let modify_other_keys_mode = enable_client_protocols @@ -360,15 +370,42 @@ fn setup_terminal_with_capabilities( Ok(TerminalGuard { reset_modify_other_keys: modify_other_keys_mode.is_some(), + reset_host_color_scheme_reports: host_color_scheme_reports, }) } +fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { + enable_client_protocols && should_query_host_terminal_theme() +} + /// Guard that restores the terminal when dropped. struct TerminalGuard { reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, +} + +fn write_host_color_scheme_report_mode( + writer: &mut impl io::Write, + enabled: bool, +) -> io::Result<()> { + let sequence = if enabled { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE + } else { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE + }; + writer.write_all(sequence.as_bytes())?; + writer.flush() } -fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<()> { +fn write_terminal_restore_postlude( + writer: &mut impl io::Write, + reset_host_color_scheme_reports: bool, +) -> io::Result<()> { + if reset_host_color_scheme_reports { + writer.write_all( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + )?; + } // Restore a visible cursor and reset DECSCUSR back to the terminal default. writer.write_all(b"\x1b[?25h\x1b[0 q")?; writer.flush() @@ -387,7 +424,7 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> { } } -fn restore_terminal_state(reset_modify_other_keys: bool) { +fn restore_terminal_state(reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool) { let _ = clear_received_kitty_graphics(&mut io::stdout()); // Reset modifyOtherKeys if we enabled it. @@ -404,7 +441,7 @@ fn restore_terminal_state(reset_modify_other_keys: bool) { DisableMouseCapture ); ratatui::restore(); - let _ = write_terminal_restore_postlude(&mut io::stdout()); + let _ = write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); } #[cfg(not(windows))] @@ -432,7 +469,10 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> { impl Drop for TerminalGuard { fn drop(&mut self) { - restore_terminal_state(self.reset_modify_other_keys); + restore_terminal_state( + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + ); } } @@ -704,9 +744,13 @@ fn run_client_with_mode( // Install a panic hook to restore the terminal on panic (same as monolithic). let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys; + let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports; let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - restore_terminal_state(panic_resets_modify_other_keys); + restore_terminal_state( + panic_resets_modify_other_keys, + panic_resets_host_color_scheme_reports, + ); original_hook(info); })); @@ -898,6 +942,9 @@ async fn run_client_loop( ) { state.request_full_redraw(); } + if crate::raw_input::events_require_host_terminal_theme_query(&events) { + query_host_terminal_theme(); + } data }; if should_bridge_clipboard_image_paste(&data, state.remote_image_paste_key) { @@ -1601,17 +1648,64 @@ mod tests { } #[test] + fn write_host_color_scheme_report_mode_emits_mode_sequences() { + let mut output = Vec::new(); + write_host_color_scheme_report_mode(&mut output, true).unwrap(); + write_host_color_scheme_report_mode(&mut output, false).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + assert_eq!(output, expected); + } + + #[test] + fn color_scheme_change_event_requests_host_theme_query() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + + assert!(crate::raw_input::events_require_host_terminal_theme_query( + &events + )); + } + + #[test] fn host_terminal_theme_query_is_disabled_on_windows() { assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); } #[test] + fn color_scheme_reports_are_enabled_only_for_full_clients() { + assert_eq!( + should_enable_host_color_scheme_reports(true), + !cfg!(windows) + ); + assert!(!should_enable_host_color_scheme_reports(false)); + } + + #[test] fn terminal_restore_postlude_restores_visible_default_cursor() { let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output).unwrap(); + write_terminal_restore_postlude(&mut output, false).unwrap(); assert_eq!(output, b"\x1b[?25h\x1b[0 q"); } + #[test] + fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, true).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); + assert_eq!(output, expected); + } + #[cfg(unix)] #[test] fn attach_escape_detaches_on_prefix_q() { diff --git a/src/config/theme.rs b/src/config/theme.rs index 87722b6e..fc601d3f 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -11,17 +11,23 @@ use tracing::warn; /// accent = "#f5c2e7" /// red = "#ff6188" /// ``` -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct ThemeConfig { /// Built-in theme name. Default: "catppuccin". pub name: Option<String>, + /// Follow host terminal light/dark appearance and switch between theme names. + pub auto_switch: bool, + /// Theme name used when `auto_switch` selects a dark appearance. + pub dark_name: Option<String>, + /// Theme name used when `auto_switch` selects a light appearance. + pub light_name: Option<String>, /// Custom overrides — applied on top of the selected base theme. pub custom: Option<CustomThemeColors>, } /// Per-token color overrides. All fields optional — only set what you want to change. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct CustomThemeColors { pub accent: Option<String>, @@ -135,6 +141,22 @@ name = "dracula" } #[test] + fn theme_auto_switch_fields_parse() { + let toml = r#" +[theme] +name = "catppuccin" +auto_switch = true +dark_name = "tokyo-night" +light_name = "catppuccin-latte" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.theme.name.as_deref(), Some("catppuccin")); + assert!(config.theme.auto_switch); + assert_eq!(config.theme.dark_name.as_deref(), Some("tokyo-night")); + assert_eq!(config.theme.light_name.as_deref(), Some("catppuccin-latte")); + } + + #[test] fn theme_custom_overrides_parse() { let toml = r##" [theme] @@ -158,6 +180,9 @@ red = "rgb(255, 85, 85)" fn theme_defaults_when_missing() { let config: Config = toml::from_str("").unwrap(); assert!(config.theme.name.is_none()); + assert!(!config.theme.auto_switch); + assert!(config.theme.dark_name.is_none()); + assert!(config.theme.light_name.is_none()); assert!(config.theme.custom.is_none()); } } diff --git a/src/main.rs b/src/main.rs index 2217bef2..3c66ff1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,18 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> { Ok(()) } +fn set_host_color_scheme_reports(enabled: bool) -> io::Result<()> { + use std::io::Write; + + let sequence = if enabled { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE + } else { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE + }; + io::stdout().write_all(sequence.as_bytes())?; + io::stdout().flush() +} + mod agent_resume; mod api; mod app; @@ -102,6 +114,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # vesper # name = "catppuccin" +# Follow host terminal light/dark appearance and switch Herdr UI themes. +# Existing manual behavior is unchanged unless this is true. +# auto_switch = false +# dark_name = "catppuccin" +# light_name = "catppuccin-latte" + # Override individual color tokens on top of the base theme. # Accepts: hex (#rrggbb), named colors, rgb(r,g,b), or panel_bg = "reset" # [theme.custom] @@ -665,6 +683,7 @@ fn main() -> io::Result<()> { DisableBracketedPaste, DisableMouseCapture ); + let _ = set_host_color_scheme_reports(false); let _ = pop_keyboard_enhancement_flags(); ratatui::restore(); original_hook(info); @@ -691,6 +710,7 @@ fn main() -> io::Result<()> { execute!(io::stdout(), DisableMouseCapture)?; } execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; + set_host_color_scheme_reports(true)?; push_keyboard_enhancement_flags()?; // Some hosts do not honor Kitty keyboard enhancement pushes for @@ -728,6 +748,7 @@ fn main() -> io::Result<()> { DisableBracketedPaste, DisableMouseCapture )?; + set_host_color_scheme_reports(false)?; ratatui::restore(); // Drop app (and all workspaces/panes) before runtime shuts down diff --git a/src/pane/osc.rs b/src/pane/osc.rs index e7b97a79..83cb47ae 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -885,22 +885,44 @@ pub(super) fn write_host_terminal_theme( terminal: &mut crate::ghostty::Terminal, theme: crate::terminal_theme::TerminalTheme, ) { - if let Some(color) = theme.foreground { - let sequence = crate::terminal_theme::osc_set_default_color_sequence( + write_host_terminal_theme_selective(terminal, theme, true, true); +} + +pub(super) fn write_host_terminal_theme_selective( + terminal: &mut crate::ghostty::Terminal, + theme: crate::terminal_theme::TerminalTheme, + foreground: bool, + background: bool, +) { + if foreground { + write_host_default_color( + terminal, crate::terminal_theme::DefaultColorKind::Foreground, - color, + theme.foreground, ); - terminal.write(sequence.as_bytes()); } - if let Some(color) = theme.background { - let sequence = crate::terminal_theme::osc_set_default_color_sequence( + if background { + write_host_default_color( + terminal, crate::terminal_theme::DefaultColorKind::Background, - color, + theme.background, ); - terminal.write(sequence.as_bytes()); } } +fn write_host_default_color( + terminal: &mut crate::ghostty::Terminal, + kind: crate::terminal_theme::DefaultColorKind, + color: Option<crate::terminal_theme::RgbColor>, +) { + let sequence = if let Some(color) = color { + crate::terminal_theme::osc_set_default_color_sequence(kind, color) + } else { + crate::terminal_theme::osc_reset_default_color_sequence(kind).to_string() + }; + terminal.write(sequence.as_bytes()); +} + pub(super) fn restore_host_terminal_theme_if_needed( core: &mut GhosttyPaneCore, pane_id: PaneId, diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index eca56975..a87b9622 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -24,8 +24,8 @@ use super::{ osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, - write_host_terminal_theme, AgentOscStateTracker, CwdOscTracker, DefaultColorEvent, - DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, + write_host_terminal_theme_selective, AgentOscStateTracker, CwdOscTracker, + DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker, }, xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, @@ -399,11 +399,18 @@ impl GhosttyPaneTerminal { pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { if let Ok(mut core) = self.core.lock() { + let foreground_unowned = !core.child_default_foreground_changed; + let background_unowned = !core.child_default_background_changed; core.host_terminal_theme = theme; - core.transient_default_color_owner_pgid = None; - core.child_default_foreground_changed = false; - core.child_default_background_changed = false; - write_host_terminal_theme(&mut core.terminal, theme); + if foreground_unowned && background_unowned { + core.transient_default_color_owner_pgid = None; + } + write_host_terminal_theme_selective( + &mut core.terminal, + theme, + foreground_unowned, + background_unowned, + ); } } @@ -646,6 +653,10 @@ impl GhosttyPaneTerminal { core.terminal.write(&bytes[written..]); terminal_responses.extend(self.drain_pending_pty_responses()); } + + if !core.child_default_foreground_changed && !core.child_default_background_changed { + core.transient_default_color_owner_pgid = None; + } } fn drain_pending_pty_responses(&self) -> Vec<Bytes> { @@ -1929,7 +1940,10 @@ fn respond_to_default_color_event( } } DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true), - DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false), + DefaultColorEvent::Reset(query) => { + mark_child_default_color_changed(core, query, false); + apply_cached_host_default_color(core, query); + } } } @@ -1975,6 +1989,15 @@ fn osc_rgb_response(command: &str, r: u8, g: u8, b: u8) -> Bytes { Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")) } +fn apply_cached_host_default_color(core: &mut GhosttyPaneCore, query: DefaultColorQuery) { + write_host_terminal_theme_selective( + &mut core.terminal, + core.host_terminal_theme, + matches!(query, DefaultColorQuery::Foreground), + matches!(query, DefaultColorQuery::Background), + ); +} + fn mark_child_default_color_changed( core: &mut GhosttyPaneCore, query: DefaultColorQuery, @@ -3386,6 +3409,57 @@ mod tests { } #[test] + fn host_theme_update_preserves_child_default_color_override() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;#112233\x07", &tx); + assert!(result.terminal_responses.is_empty()); + + pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme { + foreground: None, + background: Some(crate::terminal_theme::RgbColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }), + }); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx); + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn child_default_color_reset_restores_cached_host_color() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"\x1b]11;#112233\x07", &tx); + pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme { + foreground: None, + background: Some(crate::terminal_theme::RgbColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }), + }); + pane.process_pty_bytes(pane_id, 0, b"\x1b]111\x07", &tx); + assert!(!pane.has_transient_default_color_override()); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx); + assert_eq!( + result.terminal_responses, + vec![Bytes::from_static(b"\x1b]11;rgb:aaaa/bbbb/cccc\x1b\\")] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] fn process_pty_bytes_recovers_xtgettcap_after_osc_bel_terminator() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); diff --git a/src/raw_input.rs b/src/raw_input.rs index 57ab6274..9978cc0c 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -95,9 +95,13 @@ use std::os::fd::AsRawFd; use tokio::sync::mpsc; use crate::input::{parse_terminal_key_sequence, TerminalKey}; -use crate::terminal_theme::{parse_default_color_response, DefaultColorKind, RgbColor}; +use crate::terminal_theme::{ + parse_default_color_response, DefaultColorKind, HostAppearance, RgbColor, +}; const ESC: u8 = 0x1b; +pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n"; +pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~"; @@ -112,6 +116,7 @@ pub enum RawInputEvent { kind: DefaultColorKind, color: RgbColor, }, + HostColorSchemeChanged(HostAppearance), Unsupported, } @@ -129,6 +134,14 @@ impl RawInputFramer { self.byte_framer.host_color_query_sent(); } + pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) { + self.byte_framer.enable_host_color_scheme_change_tracking(); + } + + pub(crate) fn has_pending_input(&self) -> bool { + self.byte_framer.has_pending_input() + } + pub(crate) fn flush_timeout(&mut self) -> Vec<RawInputEvent> { Self::events_from_chunks(self.byte_framer.flush_timeout()) } @@ -159,6 +172,7 @@ pub(crate) struct RawInputByteFramer { discarded_tail_bytes: usize, host_color_replies_awaited: u8, held_pending_color_esc: bool, + host_color_scheme_change_tracking: bool, } const HOST_COLOR_QUERY_REPLIES: u8 = 2; @@ -176,6 +190,14 @@ impl RawInputByteFramer { self.held_pending_color_esc = false; } + pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) { + self.host_color_scheme_change_tracking = true; + } + + pub(crate) fn has_pending_input(&self) -> bool { + !self.buffer.is_empty() + } + pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> { let mut chunks = self.drain_available_chunks(); @@ -217,6 +239,17 @@ impl RawInputByteFramer { return chunks; } + if starts_with_incomplete_host_color_scheme_report(&self.buffer) { + tracing::debug!( + len = self.buffer.len(), + "discarding incomplete host color scheme report after input timeout" + ); + self.discard_until = Some(ControlStringFamily::HostColorSchemeCsi); + self.discarded_tail_bytes = 0; + self.buffer.clear(); + return chunks; + } + if let Some(ControlString::Incomplete { family }) = control_string(&self.buffer) { tracing::debug!( len = self.buffer.len(), @@ -291,6 +324,10 @@ impl RawInputByteFramer { }; if matches!(event, RawInputEvent::HostDefaultColor { .. }) { self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_sub(1); + } else if self.host_color_scheme_change_tracking + && matches!(event, RawInputEvent::HostColorSchemeChanged(_)) + { + self.host_color_query_sent(); } self.held_pending_color_esc = false; chunks.push(self.buffer[..consumed].to_vec()); @@ -327,6 +364,9 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> ) }), ControlStringFamily::StTerminated => buffer.last() == Some(&ESC), + ControlStringFamily::HostColorSchemeCsi => buffer + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')), } } @@ -340,6 +380,12 @@ pub(crate) fn events_require_host_surface_redraw( .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) } +pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) -> bool { + events + .iter() + .any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_))) +} + pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { let (tx, rx) = mpsc::channel(256); @@ -349,6 +395,7 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { let mut scratch = [0u8; 1024]; let mut framer = RawInputFramer::default(); framer.host_color_query_sent(); + framer.enable_host_color_scheme_change_tracking(); loop { match reader.read(&mut scratch) { @@ -357,7 +404,13 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { send_raw_input_events(framer.push(&scratch[..n]), &tx); if stdin_read_ready(&reader, 10) == Some(false) { - send_raw_input_events(framer.flush_timeout(), &tx); + let had_pending = framer.has_pending_input(); + let events = framer.flush_timeout(); + let held_escape = had_pending && events.is_empty(); + send_raw_input_events(events, &tx); + if held_escape && stdin_read_ready(&reader, 10) == Some(false) { + send_raw_input_events(framer.flush_timeout(), &tx); + } } } Err(_) => break, @@ -497,6 +550,10 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { _ => {} } + if let Some(appearance) = parse_host_color_scheme_report(&buffer[..seq_len]) { + return Some((RawInputEvent::HostColorSchemeChanged(appearance), seq_len)); + } + if let Some(mouse) = parse_sgr_mouse(seq) { return Some((RawInputEvent::Mouse(mouse), seq_len)); } @@ -519,6 +576,7 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { enum ControlStringFamily { Osc, StTerminated, + HostColorSchemeCsi, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -532,6 +590,14 @@ enum ControlString { }, } +fn parse_host_color_scheme_report(buffer: &[u8]) -> Option<HostAppearance> { + match buffer { + GHOSTTY_COLOR_SCHEME_DARK_REPORT => Some(HostAppearance::Dark), + GHOSTTY_COLOR_SCHEME_LIGHT_REPORT => Some(HostAppearance::Light), + _ => None, + } +} + fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool { matches!( control_string(buffer), @@ -541,6 +607,13 @@ fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool { ) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;")) } +fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool { + buffer.starts_with(b"\x1b[?") + && (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer) + || GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.starts_with(buffer)) + && buffer.len() < GHOSTTY_COLOR_SCHEME_DARK_REPORT.len() +} + fn control_string(buffer: &[u8]) -> Option<ControlString> { let family = match buffer.get(..2)? { b"\x1b]" => ControlStringFamily::Osc, @@ -650,6 +723,10 @@ fn control_string_terminator_for_family( match family { ControlStringFamily::Osc => osc_string_terminator(buffer), ControlStringFamily::StTerminated => st_string_terminator(buffer), + ControlStringFamily::HostColorSchemeCsi => buffer + .iter() + .position(|byte| *byte == b'n') + .map(|idx| idx + 1), } } @@ -937,6 +1014,69 @@ mod tests { } #[test] + fn parses_ghostty_color_scheme_reports() { + for bytes in [ + GHOSTTY_COLOR_SCHEME_DARK_REPORT, + GHOSTTY_COLOR_SCHEME_LIGHT_REPORT, + ] { + let events = parse_raw_input_bytes_sync(bytes); + assert_eq!(events.len(), 1, "bytes: {bytes:?}"); + assert!(matches!( + events[0], + RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark | HostAppearance::Light) + )); + assert!(events_require_host_terminal_theme_query(&events)); + } + } + + #[test] + fn ghostty_color_scheme_report_parser_is_exact() { + for bytes in [ + b"\x1b[?997;0n".as_slice(), + b"\x1b[?997;3n".as_slice(), + b"\x1b[?998;1n".as_slice(), + ] { + let events = parse_raw_input_bytes_sync(bytes); + assert_eq!(events.len(), 1, "bytes: {bytes:?}"); + assert!(matches!(events[0], RawInputEvent::Unsupported)); + assert!(!events_require_host_terminal_theme_query(&events)); + } + } + + #[test] + fn raw_input_framer_reassembles_split_color_scheme_report() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b[?997;").is_empty()); + let events = framer.push(b"1n"); + + assert_eq!(events.len(), 1); + assert!(matches!( + events[0], + RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark) + )); + } + + #[test] + fn split_color_scheme_timeout_does_not_swallow_legacy_alt_bracket() { + let mut framer = RawInputByteFramer::default(); + + assert!(framer.push(b"\x1b[").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b[".to_vec()]); + } + + #[test] + fn raw_input_byte_framer_discards_timed_out_split_color_scheme_report_tail() { + let mut framer = RawInputByteFramer::default(); + + assert!(framer.push(b"\x1b[?997;").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"1n").is_empty()); + assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]); + assert!(framer.flush_timeout().is_empty()); + } + + #[test] fn parses_xterm_alt_up_arrow() { let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[1;3A").unwrap() else { panic!("expected key"); @@ -1800,6 +1940,75 @@ mod tests { } #[test] + fn default_byte_framer_does_not_rearm_after_color_scheme_report() { + let mut framer = RawInputByteFramer::default(); + + assert_eq!( + framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT), + vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()] + ); + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); + } + + #[test] + fn opt_in_does_not_delay_plain_escape_without_color_scheme_report() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_color_scheme_change_tracking(); + + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); + } + + #[test] + fn opted_in_byte_framer_rearms_after_color_scheme_report() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_color_scheme_change_tracking(); + + assert_eq!( + framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT), + vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()] + ); + + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let chunks = framer.push(b"]10;#abcdef\x07"); + assert_eq!(chunks.len(), 1); + let (event, _) = extract_one_event(&chunks[0]).unwrap(); + assert!(matches!( + event, + RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + color: RgbColor { + r: 0xab, + g: 0xcd, + b: 0xef + } + } + )); + + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let chunks = framer.push(b"]11;#123456\x07"); + assert_eq!(chunks.len(), 1); + let (event, _) = extract_one_event(&chunks[0]).unwrap(); + assert!(matches!( + event, + RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Background, + color: RgbColor { + r: 0x12, + g: 0x34, + b: 0x56 + } + } + )); + + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); + } + + #[test] fn flushes_lone_escape_when_not_awaiting_host_color_reply() { let mut framer = RawInputByteFramer::default(); diff --git a/src/server/clients.rs b/src/server/clients.rs index 611d87eb..eb823cfc 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -33,6 +33,10 @@ pub(crate) struct ClientConnection { pub(crate) cell_size: crate::kitty_graphics::HostCellSize, /// Last known host terminal default colors for this client. pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last known host terminal appearance for this client. + pub(crate) host_terminal_appearance: Option<crate::terminal_theme::HostAppearance>, + /// True when appearance came from an explicit host color-scheme report. + pub(crate) host_terminal_appearance_explicit: bool, /// Last reported focus state for this client's outer terminal. pub(crate) outer_terminal_focus: Option<bool>, /// Stateful parser for app-client input split across transport reads. @@ -98,6 +102,10 @@ impl ClientConnection { keybindings, terminal_size, cell_size, + host_terminal_appearance: host_terminal_theme + .background + .map(crate::terminal_theme::RgbColor::inferred_appearance), + host_terminal_appearance_explicit: false, host_terminal_theme, outer_terminal_focus, raw_input: crate::raw_input::RawInputFramer::default(), @@ -130,17 +138,47 @@ impl ClientConnection { events: &[crate::raw_input::RawInputEvent], ) -> bool { let mut next_theme = self.host_terminal_theme; + let mut changed = false; for event in events { - if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event { - next_theme = next_theme.with_color(*kind, *color); + match event { + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + next_theme = next_theme.with_color(*kind, *color); + if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) + && !self.host_terminal_appearance_explicit + { + changed |= + self.set_host_appearance(Some(color.inferred_appearance()), false); + } + } + crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { + changed |= self.set_host_appearance(Some(*appearance), true); + } + _ => {} } } - if next_theme == self.host_terminal_theme { - return false; + if next_theme != self.host_terminal_theme { + self.host_terminal_theme = next_theme; + changed = true; } + changed + } - self.host_terminal_theme = next_theme; + fn set_host_appearance( + &mut self, + appearance: Option<crate::terminal_theme::HostAppearance>, + explicit: bool, + ) -> bool { + if self.host_terminal_appearance_explicit && !explicit { + return false; + } + if self.host_terminal_appearance == appearance + && self.host_terminal_appearance_explicit == explicit + { + return false; + } + self.host_terminal_appearance = appearance; + self.host_terminal_appearance_explicit = explicit; true } diff --git a/src/server/headless.rs b/src/server/headless.rs index 67a876f9..b162b8fe 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -778,6 +778,8 @@ impl HeadlessServer { let terminal_size = client.terminal_size; let outer_terminal_focus = client.outer_terminal_focus; let host_terminal_theme = client.host_terminal_theme; + let host_terminal_appearance = client.host_terminal_appearance; + let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit; let uses_local_keybindings = client.keybindings.is_some(); let keybindings = client .keybindings @@ -792,9 +794,11 @@ impl HeadlessServer { if outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } - if !host_terminal_theme.is_empty() { - self.app.set_host_terminal_theme(host_terminal_theme); - } + self.app.set_host_terminal_appearance_state( + host_terminal_appearance, + host_terminal_appearance_explicit, + ); + self.app.set_host_terminal_theme(host_terminal_theme); } #[cfg(unix)] @@ -1229,7 +1233,11 @@ impl HeadlessServer { } if self.foreground_client_id == Some(client_id) { - let changed = self.app.set_host_terminal_theme(client.host_terminal_theme); + let mut changed = self.app.set_host_terminal_appearance_state( + client.host_terminal_appearance, + client.host_terminal_appearance_explicit, + ); + changed |= self.app.set_host_terminal_theme(client.host_terminal_theme); if changed { self.resize_shared_runtime_to_effective_size_before_input(); } @@ -5490,6 +5498,144 @@ next_tab = "" } #[test] + fn foreground_client_without_host_theme_clears_previous_host_theme() { + let mut server = test_headless_server(); + let known_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0x10, + g: 0x20, + b: 0x30, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0x40, + g: 0x50, + b: 0x60, + }), + }; + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + known_theme, + None, + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + + assert!(server.promote_client_to_foreground(1)); + assert_eq!(server.app.state.host_terminal_theme, known_theme); + + assert!(server.promote_client_to_foreground(2)); + assert_eq!( + server.app.state.host_terminal_theme, + crate::terminal_theme::TerminalTheme::default() + ); + } + + #[test] + fn foreground_client_appearance_controls_auto_theme() { + let mut server = test_headless_server(); + server.app.state.theme_runtime.auto_switch = true; + server.app.state.theme_runtime.dark_name = "catppuccin".to_string(); + server.app.state.theme_runtime.light_name = "catppuccin-latte".to_string(); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme { + foreground: None, + background: Some(crate::terminal_theme::RgbColor { r: 0, g: 0, b: 0 }), + }, + None, + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme { + foreground: None, + background: Some(crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }), + }, + None, + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + + assert!(server.promote_client_to_foreground(1)); + assert_eq!(server.app.state.theme_name, "catppuccin"); + + assert!(server.promote_client_to_foreground(2)); + assert_eq!(server.app.state.theme_name, "catppuccin-latte"); + } + + #[test] + fn color_scheme_change_event_is_inert_on_server() { + let mut server = test_headless_server(); + let initial_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0x10, + g: 0x20, + b: 0x30, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0x40, + g: 0x50, + b: 0x60, + }), + }; + server.app.state.host_terminal_theme = initial_theme; + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + initial_theme, + None, + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + + let changed = server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec(), + }); + + assert!(!changed); + assert_eq!(server.foreground_client_id, None); + assert_eq!(server.clients[&1].host_terminal_theme, initial_theme); + assert_eq!(server.app.state.host_terminal_theme, initial_theme); + } + + #[test] fn focus_lost_updates_client_without_promoting_foreground() { let mut server = test_headless_server(); diff --git a/src/terminal_theme.rs b/src/terminal_theme.rs index 523e3e38..5627ab1b 100644 --- a/src/terminal_theme.rs +++ b/src/terminal_theme.rs @@ -5,6 +5,23 @@ pub struct RgbColor { pub b: u8, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostAppearance { + Dark, + Light, +} + +impl RgbColor { + pub fn inferred_appearance(self) -> HostAppearance { + let luminance = u32::from(self.r) * 299 + u32::from(self.g) * 587 + u32::from(self.b) * 114; + if luminance >= 128_000 { + HostAppearance::Light + } else { + HostAppearance::Dark + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct TerminalTheme { pub foreground: Option<RgbColor>, @@ -18,6 +35,8 @@ pub enum DefaultColorKind { } pub const HOST_COLOR_QUERY_SEQUENCE: &str = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\"; +pub const HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE: &str = "\x1b[?2031h"; +pub const HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE: &str = "\x1b[?2031l"; impl TerminalTheme { pub fn with_color(mut self, kind: DefaultColorKind, color: RgbColor) -> Self { @@ -58,6 +77,13 @@ pub fn osc_set_default_color_sequence(kind: DefaultColorKind, color: RgbColor) - ) } +pub fn osc_reset_default_color_sequence(kind: DefaultColorKind) -> &'static str { + match kind { + DefaultColorKind::Foreground => "\x1b]110\x1b\\", + DefaultColorKind::Background => "\x1b]111\x1b\\", + } +} + fn parse_rgb_color(value: &str) -> Option<RgbColor> { if let Some(rgb) = value.strip_prefix("rgb:") { let mut parts = rgb.split('/'); @@ -133,6 +159,18 @@ mod tests { } #[test] + fn default_color_reset_sequences_use_xterm_osc_numbers() { + assert_eq!( + osc_reset_default_color_sequence(DefaultColorKind::Foreground), + "\x1b]110\x1b\\" + ); + assert_eq!( + osc_reset_default_color_sequence(DefaultColorKind::Background), + "\x1b]111\x1b\\" + ); + } + + #[test] fn scales_short_hex_components() { assert_eq!(parse_hex_component("f"), Some(255)); assert_eq!(parse_hex_component("80"), Some(128)); |
