From babb09bd53221ca0cdfcaab178ff992c2daa0aeb Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 23 Oct 2026 15:22:40 +0000 Subject: [PATCH] fix: send pane appearance changes only after the matching background refs #4183 --- src/app/agent_resume.rs | 2 +- src/app/api.rs | 2 +- src/app/api/layouts.rs | 4 +- src/app/api/panes.rs | 2 +- src/app/api/plugins/panes.rs | 6 +- src/app/api/tabs.rs | 2 +- src/app/creation.rs | 2 +- src/app/custom_commands.rs | 6 +- src/app/mod.rs | 2 + src/app/popup.rs | 4 +- src/app/runtime.rs | 1 + src/app/theme_sync.rs | 92 ++++++++++++--- src/app/theme_sync/tests.rs | 186 +++++++++++++++++++++++++++++++ src/pane.rs | 5 +- src/server/headless.rs | 9 +- src/server/headless/tests/mod.rs | 95 ++++++++++++++++ src/terminal/runtime.rs | 4 +- 17 files changed, 390 insertions(+), 34 deletions(-) create mode 100644 src/app/theme_sync/tests.rs diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index f088138c7..a90f6b1d2 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -270,7 +270,7 @@ impl App { cwd, self.state.pane_scrollback_limit_bytes, host_terminal_theme, - self.state.host_terminal_appearance, + self.pane_host_terminal_appearance(), crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), &launch_env, self.event_tx.clone(), diff --git a/src/app/api.rs b/src/app/api.rs index 55124aa1e..067ec2fa8 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -614,7 +614,7 @@ impl App { cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - self.state.host_terminal_appearance, + self.pane_host_terminal_appearance(), crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), &launch_env, self.event_tx.clone(), diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index bde3421f0..6a6d2db5d 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -99,7 +99,7 @@ impl App { let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; - let host_terminal_appearance = self.state.host_terminal_appearance; + let host_terminal_appearance = self.pane_host_terminal_appearance(); let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) { Ok(env) => env, Err((code, message)) => return encode_error(id, &code, message), @@ -415,7 +415,7 @@ impl App { let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; - let host_terminal_appearance = self.state.host_terminal_appearance; + let host_terminal_appearance = self.pane_host_terminal_appearance(); let cwd = pane .cwd .as_ref() diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index b5f3baf67..24b391248 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -71,7 +71,7 @@ impl App { let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; - let host_terminal_appearance = self.state.host_terminal_appearance; + let host_terminal_appearance = self.pane_host_terminal_appearance(); let previous_focus = self.state.current_pane_focus_target(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "pane_not_found", "pane not found"); diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index a4f30b0ce..2fc9f24be 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -127,6 +127,7 @@ impl App { }; let (rows, cols) = self.state.new_pane_size(new_pane_placement); let previous_focus = self.state.current_pane_focus_target(); + let host_terminal_appearance = self.pane_host_terminal_appearance(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "workspace_not_found", "workspace not found"); }; @@ -140,7 +141,7 @@ impl App { extra_env, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - self.state.host_terminal_appearance, + host_terminal_appearance, params.focus || placement == PluginPanePlacement::Zoomed, ); let (tab_idx, new_pane) = match result { @@ -206,6 +207,7 @@ impl App { Err((code, message)) => return encode_error(id, &code, message), }; let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone); + let host_terminal_appearance = self.pane_host_terminal_appearance(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "workspace_not_found", "workspace not found"); }; @@ -217,7 +219,7 @@ impl App { extra_env, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - self.state.host_terminal_appearance, + host_terminal_appearance, ) { Ok(result) => result, Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index b00858592..7c7fb76a0 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -69,7 +69,7 @@ impl App { let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; - let host_terminal_appearance = self.state.host_terminal_appearance; + let host_terminal_appearance = self.pane_host_terminal_appearance(); let extra_env = match super::env::normalize_launch_env(env) { Ok(env) => env, Err((code, message)) => return encode_error(id, &code, message), diff --git a/src/app/creation.rs b/src/app/creation.rs index 21e976ce8..dde970cb6 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -136,7 +136,7 @@ impl App { cols, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - self.state.host_terminal_appearance, + self.pane_host_terminal_appearance(), crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), self.event_tx.clone(), self.render_notify.clone(), diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs index 6e8a6c016..55b84228c 100644 --- a/src/app/custom_commands.rs +++ b/src/app/custom_commands.rs @@ -373,6 +373,7 @@ impl App { .state .new_pane_size(crate::ui::NewPanePlacement::ZoomedOverlay); let (env, _) = self.custom_command_env(); + let host_terminal_appearance = self.pane_host_terminal_appearance(); let ws = self .state @@ -400,7 +401,7 @@ impl App { env, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - self.state.host_terminal_appearance, + host_terminal_appearance, )?; let new_pane_id = new_pane.pane_id; self.terminal_runtimes @@ -470,6 +471,7 @@ impl App { }) }); + let host_terminal_appearance = self.pane_host_terminal_appearance(); let (tab_idx, new_pane, workspace_id) = { let ws = self .state @@ -487,7 +489,7 @@ impl App { extra_env, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - self.state.host_terminal_appearance, + host_terminal_appearance, true, ); let (tab_idx, new_pane) = match result { diff --git a/src/app/mod.rs b/src/app/mod.rs index e2daa74a2..d7a8cd56e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -140,6 +140,7 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option, pub(crate) pending_agent_resume_deadline: Option, + pub(crate) pending_host_appearance_deadline: Option, startup_per_agent_delay: Duration, next_agent_resume_at: Option, pub(crate) session_save_deadline: Option, @@ -652,6 +653,7 @@ impl App { loaded_host_cursor: config.ui.host_cursor, agent_metadata_deadline: None, pending_agent_resume_deadline: None, + pending_host_appearance_deadline: None, startup_per_agent_delay: Duration::from_millis( config.session.startup_per_agent_delay_ms.into(), ), diff --git a/src/app/popup.rs b/src/app/popup.rs index 988b3cb6f..d7b1057f3 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -54,7 +54,7 @@ impl App { crate::pane::AgentDetection::Disabled, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, - app.state.host_terminal_appearance, + app.pane_host_terminal_appearance(), app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), @@ -86,7 +86,7 @@ impl App { crate::pane::AgentDetection::Disabled, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, - app.state.host_terminal_appearance, + app.pane_host_terminal_appearance(), app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index c668087a0..223803c9e 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -167,6 +167,7 @@ impl App { self.next_agent_manifest_update_check, self.agent_metadata_deadline, self.pending_agent_resume_deadline, + self.pending_host_appearance_deadline, self.session_save_deadline, self.next_tab_bar_status_deadline(), render_deadline, diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index ed0f632d3..b0e918318 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -1,31 +1,97 @@ +use std::time::{Duration, Instant}; + use super::App; +use crate::terminal_theme::HostAppearance; + +#[cfg(test)] +mod tests; impl App { + #[cfg(test)] pub(crate) fn set_host_terminal_appearance_state( &mut self, - appearance: Option, + appearance: Option, 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.apply_host_terminal_appearance_to_panes(); - self.refresh_effective_app_theme() + self.set_host_terminal_state(self.state.host_terminal_theme, appearance, explicit) } + #[cfg(test)] pub(crate) fn set_host_terminal_theme( &mut self, theme: crate::terminal_theme::TerminalTheme, ) -> bool { - if theme == self.state.host_terminal_theme { + self.set_host_terminal_state( + theme, + self.state.host_terminal_appearance, + self.state.host_terminal_appearance_explicit, + ) + } + + pub(crate) fn set_host_terminal_state( + &mut self, + theme: crate::terminal_theme::TerminalTheme, + appearance: Option, + explicit: bool, + ) -> bool { + let theme_changed = theme != self.state.host_terminal_theme; + if theme_changed { + self.state.host_terminal_theme = theme; + self.apply_host_terminal_theme_to_panes(); + } + + let previous = self.state.host_terminal_appearance; + let appearance_changed = + previous != appearance || self.state.host_terminal_appearance_explicit != explicit; + if appearance_changed { + self.state.host_terminal_appearance = appearance; + self.state.host_terminal_appearance_explicit = explicit; + if previous.is_some() && appearance.is_some() { + if previous != appearance { + // The matching OSC 11 arrives after the appearance report's round trip. + self.pending_host_appearance_deadline + .get_or_insert(Instant::now() + Duration::from_millis(750)); + } + } else { + self.pending_host_appearance_deadline = None; + self.apply_host_terminal_appearance_to_panes(); + } + } + + // A foreground switch must install both facts before releasing the outgoing hold. + let released = self.release_host_appearance_if_ready(Instant::now()); + let repainted = appearance_changed && self.refresh_effective_app_theme(); + theme_changed || released || repainted + } + + pub(crate) fn pane_host_terminal_appearance(&self) -> Option { + if self.pending_host_appearance_deadline.is_some() { + // New panes must describe the background available during the hold. + self.state + .host_terminal_theme + .background + .map(|color| color.inferred_appearance()) + } else { + self.state.host_terminal_appearance + } + } + + pub(crate) fn release_host_appearance_if_ready(&mut self, now: Instant) -> bool { + let Some(deadline) = self.pending_host_appearance_deadline else { + return false; + }; + let matches = self + .state + .host_terminal_theme + .background + .is_some_and(|color| { + Some(color.inferred_appearance()) == self.state.host_terminal_appearance + }); + if !matches && now < deadline { return false; } - self.state.host_terminal_theme = theme; - self.apply_host_terminal_theme_to_panes(); + self.pending_host_appearance_deadline = None; + self.apply_host_terminal_appearance_to_panes(); true } diff --git a/src/app/theme_sync/tests.rs b/src/app/theme_sync/tests.rs new file mode 100644 index 000000000..ee1196b9f --- /dev/null +++ b/src/app/theme_sync/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::terminal::{TerminalId, TerminalRuntime}; +use crate::terminal_theme::{RgbColor, TerminalTheme}; +use bytes::Bytes; +use tokio::sync::mpsc; + +const DARK: RgbColor = RgbColor { + r: 17, + g: 17, + b: 17, +}; +const LIGHT: RgbColor = RgbColor { + r: 255, + g: 255, + b: 255, +}; + +fn app() -> App { + let (_, api_rx) = mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ) +} + +fn child(app: &mut App) -> (TerminalId, mpsc::Receiver) { + let (runtime, rx) = TerminalRuntime::test_with_channel(80, 24); + runtime.apply_host_terminal_theme(app.state.host_terminal_theme); + runtime.apply_host_terminal_appearance(app.pane_host_terminal_appearance()); + runtime.test_process_pty_bytes(b"\x1b[?2031h"); + let id = TerminalId::alloc(); + app.terminal_runtimes.insert(id.clone(), runtime); + (id, rx) +} + +fn background(app: &mut App, color: RgbColor) { + app.set_host_terminal_theme(TerminalTheme { + background: Some(color), + ..app.state.host_terminal_theme + }); +} + +fn query(app: &App, id: &TerminalId, request: &[u8]) -> Vec { + app.terminal_runtimes + .get(id) + .unwrap() + .test_process_pty_bytes(request) +} + +#[tokio::test] +async fn appearance_waits_for_background_not_foreground_or_palette() { + let mut app = app(); + background(&mut app, DARK); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + let (id, mut rx) = child(&mut app); + + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + assert!(rx.try_recv().is_err(), "notification must wait for OSC 11"); + let mut theme = app.state.host_terminal_theme; + theme.foreground = Some(DARK); + theme.palette[1] = Some(LIGHT); + app.set_host_terminal_theme(theme); + assert!( + rx.try_recv().is_err(), + "OSC 10/palette must not release the hold" + ); + assert_eq!( + query(&app, &id, b"\x1b[?996n"), + [Bytes::from_static(b"\x1b[?997;1n")] + ); + background(&mut app, LIGHT); + assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]); + assert_eq!( + query(&app, &id, b"\x1b]11;?\x07"), + [Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")] + ); +} + +#[tokio::test] +async fn appearance_with_matching_background_is_immediate() { + let mut app = app(); + background(&mut app, DARK); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + let (_, mut rx) = child(&mut app); + background(&mut app, LIGHT); + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]); + assert!(app.pending_host_appearance_deadline.is_none()); +} + +#[tokio::test] +async fn appearance_deadline_bounds_missing_or_inconsistent_background() { + for missing in [false, true] { + let mut app = app(); + background(&mut app, DARK); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + let (_, mut rx) = child(&mut app); + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + if missing { + app.set_host_terminal_theme(TerminalTheme::default()); + } + let deadline = app.pending_host_appearance_deadline.unwrap(); + assert!(deadline <= Instant::now() + Duration::from_millis(750)); + assert!(!app.release_host_appearance_if_ready(deadline - Duration::from_nanos(1))); + assert!(rx.try_recv().is_err()); + assert!(app.release_host_appearance_if_ready(deadline)); + assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]); + assert!(!app.release_host_appearance_if_ready(deadline)); + } +} + +#[tokio::test] +async fn appearance_coalesces_without_extending_deadline() { + let mut app = app(); + background(&mut app, DARK); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + let (_, mut rx) = child(&mut app); + app.set_host_terminal_theme(TerminalTheme::default()); + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + let deadline = app.pending_host_appearance_deadline; + // A provenance-only update must not cancel a pending flip either. + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), false); + assert_eq!(app.pending_host_appearance_deadline, deadline); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + assert_eq!(app.pending_host_appearance_deadline, deadline); + assert!(rx.try_recv().is_err()); + background(&mut app, LIGHT); + assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]); + assert!(rx.try_recv().is_err()); +} + +#[tokio::test] +async fn appearance_baseline_establish_and_clear_are_immediate() { + let mut app = app(); + let (id, mut rx) = child(&mut app); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + assert_eq!( + query(&app, &id, b"\x1b[?996n"), + [Bytes::from_static(b"\x1b[?997;1n")] + ); + assert!(app.pending_host_appearance_deadline.is_none()); + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + app.set_host_terminal_appearance_state(None, false); + assert!(app.pending_host_appearance_deadline.is_none()); + assert!(query(&app, &id, b"\x1b[?996n").is_empty()); + assert!(rx.try_recv().is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn pane_created_during_appearance_hold_gets_consistent_pair() { + let mut app = app(); + app.state.default_shell = "/bin/sh".into(); + background(&mut app, DARK); + app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + let index = app + .create_workspace_with_options(std::env::temp_dir(), false) + .unwrap(); + let ws = &app.state.workspaces[index]; + let id = ws.terminal_id(ws.tabs[0].root_pane).unwrap().clone(); + assert_eq!( + query(&app, &id, b"\x1b[?996n"), + [Bytes::from_static(b"\x1b[?997;1n")] + ); + assert_eq!( + query(&app, &id, b"\x1b]11;?\x07"), + [Bytes::from_static(b"\x1b]11;rgb:1111/1111/1111\x1b\\")] + ); + background(&mut app, LIGHT); + assert_eq!( + query(&app, &id, b"\x1b[?996n"), + [Bytes::from_static(b"\x1b[?997;2n")] + ); + assert_eq!( + query(&app, &id, b"\x1b]11;?\x07"), + [Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")] + ); + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } +} diff --git a/src/pane.rs b/src/pane.rs index f80baeb82..dd53d846d 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -4019,16 +4019,17 @@ impl PaneRuntime { (release_tx, writer) } - pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) { + pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec { let _content_write_guard = match self.content_write_lock.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; self.content_seq.fetch_add(1, Ordering::AcqRel); let (tx, _rx) = mpsc::channel(1); - let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx); + let result = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx); self.content_seq.fetch_add(1, Ordering::Release); self.compression.wake(); + result.terminal_responses } pub(crate) fn test_with_scrollback_bytes( diff --git a/src/server/headless.rs b/src/server/headless.rs index cb2c61964..60b383c48 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -845,11 +845,11 @@ impl HeadlessServer { if outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } - self.app.set_host_terminal_appearance_state( + self.app.set_host_terminal_state( + host_terminal_theme, host_terminal_appearance, host_terminal_appearance_explicit, ); - self.app.set_host_terminal_theme(host_terminal_theme); } fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) { @@ -2270,11 +2270,11 @@ impl HeadlessServer { if !client.shell_surface_active || self.foreground_client_id != Some(client_id) { return false; } - let mut changed = self.app.set_host_terminal_appearance_state( + let changed = self.app.set_host_terminal_state( + client.host_terminal_theme, 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(); } @@ -3365,6 +3365,7 @@ impl HeadlessServer { changed = true; } + changed |= self.app.release_host_appearance_if_ready(now); changed |= self.app.handle_tab_bar_status_tasks(now); if geometry_dirty { diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 3ab7ec0fd..cd7419276 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -4019,6 +4019,101 @@ fn client_shell_host_theme_follows_foreground_client() { assert!(!server.app.state.host_terminal_appearance_explicit); } +#[tokio::test] +async fn foreground_switch_coalesces_pending_appearance_before_releasing_colors() { + use crate::terminal::{TerminalId, TerminalRuntime}; + use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme}; + + for incoming in [Some(HostAppearance::Dark), None] { + let mut server = test_headless_server(); + server.app.set_host_terminal_theme(TerminalTheme { + background: Some(RgbColor { + r: 17, + g: 17, + b: 17, + }), + ..Default::default() + }); + server + .app + .set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); + runtime.apply_host_terminal_theme(server.app.state.host_terminal_theme); + runtime.apply_host_terminal_appearance(Some(HostAppearance::Dark)); + runtime.test_process_pty_bytes(b"\x1b[?2031h"); + let id = TerminalId::alloc(); + server.app.terminal_runtimes.insert(id.clone(), runtime); + server + .app + .set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + assert!(rx.try_recv().is_err()); + + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ); + client.host_terminal_theme.background = Some(RgbColor { + r: 255, + g: 255, + b: 255, + }); + client.host_terminal_appearance = incoming; + client.host_terminal_appearance_explicit = incoming.is_some(); + server.clients.insert(2, client); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + assert!( + rx.try_recv().is_err(), + "must not announce outgoing client A Light" + ); + let expected = + incoming.map(|appearance| bytes::Bytes::from_static(appearance.color_scheme_report())); + assert_eq!( + server + .app + .terminal_runtimes + .get(&id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?996n"), + expected.into_iter().collect::>() + ); + } +} + +#[test] +fn pending_host_appearance_is_released_by_headless_deadline() { + use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme}; + + let mut server = test_headless_server(); + server.app.set_host_terminal_theme(TerminalTheme { + background: Some(RgbColor { + r: 17, + g: 17, + b: 17, + }), + ..Default::default() + }); + server + .app + .set_host_terminal_appearance_state(Some(HostAppearance::Dark), true); + server + .app + .set_host_terminal_appearance_state(Some(HostAppearance::Light), true); + let deadline = server.app.pending_host_appearance_deadline.unwrap(); + assert!( + server + .app + .next_headless_loop_deadline_with_git_refresh(Instant::now(), false, false) + .unwrap() + <= deadline + ); + server.handle_scheduled_tasks_headless(deadline, false); + assert!(server.app.pending_host_appearance_deadline.is_none()); +} + #[test] fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() { let mut server = test_headless_server(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 23b61da26..6bc38694f 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -657,8 +657,8 @@ impl TerminalRuntime { )) } - pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) { - self.0.test_process_pty_bytes(bytes); + pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec { + self.0.test_process_pty_bytes(bytes) } pub(crate) fn test_with_scrollback_bytes(