diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-28 17:16:27 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-28 17:16:27 +0200 |
| commit | d5680d84fd1df3b592424eae5c50d54642f0727d (patch) | |
| tree | f8849a87322470c3e79ff6a4b53d6cd0baa59a11 /src | |
| parent | 267d47fb571c0a5e771f2e31bdf6459d625d7459 (diff) | |
fix: capture Windows host default colors at startup (#4710)
refs #1530
refs #4369
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/input.rs | 27 | ||||
| -rw-r--r-- | src/client/input/windows_vti.rs | 303 | ||||
| -rw-r--r-- | src/client/mod.rs | 13 | ||||
| -rw-r--r-- | src/client/shell_runtime.rs | 110 | ||||
| -rw-r--r-- | src/client/terminal_geometry.rs | 4 | ||||
| -rw-r--r-- | src/client/terminal_setup.rs | 5 | ||||
| -rw-r--r-- | src/client/tests/mod.rs | 5 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 17 | ||||
| -rw-r--r-- | src/raw_input.rs | 329 |
9 files changed, 773 insertions, 40 deletions
diff --git a/src/client/input.rs b/src/client/input.rs index 1e308d54..ab46e788 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -51,14 +51,13 @@ pub fn stdin_reader_loop( #[cfg(windows)] { let _ = ( - host_color_query_sent, host_theme_query_pending, host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, ); let _ = (host_escape_disambiguation_active, initial_host_input); - windows_stdin_reader_loop(event_tx, should_quit); + windows_stdin_reader_loop(event_tx, should_quit, host_color_query_sent); } #[cfg(unix)] @@ -397,6 +396,7 @@ fn idle_flush_timeout_ms( fn windows_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, + host_color_query_sent: bool, ) { if !super::windows_vti_input_backend_enabled() { windows_vti::trace_input_transport("reader=crossterm"); @@ -405,7 +405,12 @@ fn windows_stdin_reader_loop( match windows_vti::console_input_handle() { Ok(handle) => { windows_vti::trace_input_transport("reader=windows-console"); - windows_vti::raw_console_reader_loop(handle, event_tx, should_quit); + windows_vti::raw_console_reader_loop( + handle, + event_tx, + should_quit, + host_color_query_sent, + ); } _ => { windows_vti::trace_input_transport("reader=crossterm-fallback"); @@ -650,8 +655,20 @@ fn windows_client_input_event_from_raw( crate::raw_input::RawInputEvent::OuterFocusLost => { Some(crate::protocol::ClientInputEvent::FocusLost) } - crate::raw_input::RawInputEvent::HostDefaultColor { .. } - | crate::raw_input::RawInputEvent::HostPaletteColors { .. } + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + Some(crate::protocol::ClientInputEvent::HostDefaultColor { + kind: match kind { + crate::terminal_theme::DefaultColorKind::Foreground => { + crate::protocol::ClientHostDefaultColorKind::Foreground + } + crate::terminal_theme::DefaultColorKind::Background => { + crate::protocol::ClientHostDefaultColorKind::Background + } + }, + color: color.into(), + }) + } + crate::raw_input::RawInputEvent::HostPaletteColors { .. } | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) | crate::raw_input::RawInputEvent::HostCellSizeReport { .. } | crate::raw_input::RawInputEvent::Unsupported => None, diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 0deb3185..c4642885 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -20,9 +20,16 @@ pub(super) fn raw_console_reader_loop( handle: windows_sys::Win32::Foundation::HANDLE, event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, + host_color_query_sent: bool, ) { let mut mapper = WindowsInputMapper::default(); let mut pump = WindowsInputPump::default(); + if host_color_query_sent { + // Native records distinguish physical Escape from scan-code-zero host replies. + pump.framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + super::super::query_host_terminal_theme(); + } let mut handoff = WindowsInputHandoff::default(); while !should_quit.load(Ordering::Acquire) { @@ -325,10 +332,12 @@ struct WindowsInputMapper { pending_paste_high_surrogate: Option<u16>, mouse_buttons: WindowsMouseButtons, win32_input: WindowsWin32InputModeFramer, + pending_escape_origin: Option<EscapeOrigin>, } struct WindowsInputPump { framer: crate::raw_input::RawInputFramer, + pending_escape_origin: Option<EscapeOrigin>, paste_from_win32_key_records: bool, pending_physical_escape: Option<(crate::protocol::ClientInputEvent, bool)>, default_mouse_candidate: DefaultMouseCandidate, @@ -346,6 +355,7 @@ impl Default for WindowsInputPump { fn default() -> Self { Self { framer: crate::raw_input::RawInputFramer::for_host_input(), + pending_escape_origin: None, paste_from_win32_key_records: false, pending_physical_escape: None, default_mouse_candidate: DefaultMouseCandidate::default(), @@ -357,6 +367,7 @@ impl Default for WindowsInputPump { #[derive(Clone, Debug, PartialEq, Eq)] enum PlatformInputItem { Bytes(Vec<u8>), + Escape(EscapeOrigin), Semantic(crate::protocol::ClientInputEvent), PasteAwareBytes { paste_bytes: Vec<u8>, @@ -372,6 +383,12 @@ enum PlatformInputItem { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EscapeOrigin { + HostReply, + Keyboard, +} + #[derive(Default)] struct WindowsWin32InputModeFramer { buffer: Vec<u8>, @@ -386,6 +403,17 @@ enum WindowsWin32InputModeItem { } impl WindowsInputPump { + fn flush_before_semantic(&mut self) -> Vec<crate::raw_input::RawInputEvent> { + // A scan-code-zero reply prefix can outlive an unrelated keyboard record. + if self.pending_escape_origin == Some(EscapeOrigin::HostReply) + && self.framer.holds_host_default_color_escape() + { + return Vec::new(); + } + self.pending_escape_origin = None; + self.framer.flush_interrupted() + } + fn process(&mut self, item: PlatformInputItem) -> Vec<crate::protocol::ClientInputEvent> { let mut events = Vec::new(); if let Some((escape, open_bracket)) = self.pending_physical_escape.take() { @@ -412,7 +440,7 @@ impl WindowsInputPump { } if let Some(escape) = item.physical_escape_press() { if !self.framer.has_pending_bracketed_paste() { - let raw_events = self.framer.flush_interrupted(); + let raw_events = self.flush_before_semantic(); events.extend(self.process_raw_events(raw_events)); self.pending_physical_escape = Some((escape, false)); return events; @@ -421,11 +449,17 @@ impl WindowsInputPump { let mut next = match item { PlatformInputItem::Bytes(bytes) => { + self.pending_escape_origin = None; let raw_events = self.framer.push(&bytes); self.process_raw_events(raw_events) } + PlatformInputItem::Escape(origin) => { + self.pending_escape_origin = Some(origin); + let raw_events = self.framer.push(b"\x1b"); + self.process_raw_events(raw_events) + } PlatformInputItem::Semantic(event) => { - let raw_events = self.framer.flush_interrupted(); + let raw_events = self.flush_before_semantic(); let mut events = self.process_raw_events(raw_events); events.push(event); events @@ -444,6 +478,11 @@ impl WindowsInputPump { self.stage_default_mouse_record(nested_record, &raw_bytes); } let decode_win32_record = self.paste_from_win32_key_records || !pending_paste; + self.pending_escape_origin = if decode_win32_record && !pending_paste { + WindowsInputMapper::raw_escape_origin(nested_record) + } else { + None + }; let raw_events = if decode_win32_record { if pending_paste { self.framer.push(&win32_paste_bytes) @@ -487,7 +526,7 @@ impl WindowsInputPump { let raw_events = if events.is_empty() { self.framer.flush_timeout() } else { - self.framer.flush_interrupted() + self.flush_before_semantic() }; let mut output = self.process_raw_events(raw_events); output.extend(events); @@ -508,7 +547,12 @@ impl WindowsInputPump { events.extend(self.process_raw_events(raw_events)); } } - let raw_events = self.framer.flush_timeout(); + let raw_events = if self.pending_escape_origin == Some(EscapeOrigin::Keyboard) { + self.pending_escape_origin = None; + self.framer.flush_keyboard_escape() + } else { + self.framer.flush_timeout() + }; events.extend(self.process_raw_events(raw_events)); events } @@ -606,6 +650,7 @@ fn remove_matching_key(keys: &mut Vec<WindowsKeyRecord>, record: WindowsKeyRecor impl PlatformInputItem { fn raw_bytes(&self) -> Option<&[u8]> { match self { + Self::Escape(_) => Some(b"\x1b"), Self::Bytes(bytes) | Self::PasteAwareBytes { raw_bytes: bytes, .. @@ -684,10 +729,18 @@ impl WindowsInputMapper { self.win32_input .flush_timeout() .into_iter() - .map(PlatformInputItem::Bytes) + .map(|bytes| self.raw_byte_item(bytes)) .collect() } + fn raw_byte_item(&mut self, bytes: Vec<u8>) -> PlatformInputItem { + let origin = self.pending_escape_origin.take(); + match origin { + Some(origin) if bytes.as_slice() == b"\x1b" => PlatformInputItem::Escape(origin), + _ => PlatformInputItem::Bytes(bytes), + } + } + fn translate(&mut self, record: WindowsInputRecord) -> Vec<PlatformInputItem> { match record { WindowsInputRecord::Key(key) => self.translate_key(key), @@ -714,7 +767,10 @@ impl WindowsInputMapper { return Vec::new(); } - if self.key_record_is_raw_escape(key) { + if let Some(origin) = Self::raw_escape_origin(key) { + if self.win32_input.buffer.is_empty() { + self.pending_escape_origin = Some(origin); + } return self.translate_win32_input_mode_bytes(&[0x1b]); } @@ -760,13 +816,13 @@ impl WindowsInputMapper { self.with_pending_win32_flush(items) } - fn key_record_is_raw_escape(&self, key: WindowsKeyRecord) -> bool { + fn raw_escape_origin(key: WindowsKeyRecord) -> Option<EscapeOrigin> { let modifiers = windows_key_modifiers(key.control_key_state); if !key.key_down || key.repeat_count.max(1) != 1 || modifiers.contains(crossterm::event::KeyModifiers::ALT) { - return false; + return None; } // Physical Escape carries a scan code. Scan-code-zero Escape can @@ -778,17 +834,22 @@ impl WindowsInputMapper { && key.unicode == 0x1b && modifiers == crossterm::event::KeyModifiers::CONTROL; - bare_escape || ctrl_bracket + if bare_escape && key.virtual_scan_code == 0 { + Some(EscapeOrigin::HostReply) + } else if bare_escape || ctrl_bracket { + Some(EscapeOrigin::Keyboard) + } else { + None + } } fn translate_win32_input_mode_bytes(&mut self, bytes: &[u8]) -> Vec<PlatformInputItem> { let mut items = Vec::new(); for item in self.win32_input.push(bytes) { match item { - WindowsWin32InputModeItem::Bytes(bytes) => { - items.push(PlatformInputItem::Bytes(bytes)) - } + WindowsWin32InputModeItem::Bytes(bytes) => items.push(self.raw_byte_item(bytes)), WindowsWin32InputModeItem::Key { bytes, record } => { + self.pending_escape_origin = None; trace_input_transport("transport=win32-serialized"); let win32_paste_bytes = self.paste_payload_bytes_for_key(record).unwrap_or_default(); @@ -818,7 +879,7 @@ impl WindowsInputMapper { &mut self, record: WindowsKeyRecord, ) -> Option<Vec<u8>> { - if self.key_record_is_raw_escape(record) { + if Self::raw_escape_origin(record).is_some() { return Some(vec![0x1b]); } @@ -1389,6 +1450,217 @@ fn windows_input_trace_enabled() -> bool { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn host_default_color_replies_reach_shell_without_pane_input() { + use crate::protocol::{ + ClientHostColor, ClientHostDefaultColorKind, ClientHostThemeUpdate, ClientMessage, + }; + + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + assert!(translator.translate(key_char('\x1b')).is_empty()); + for _ in 0..3 { + assert!(translator.idle().is_empty()); + } + assert!(matches!( + translator + .translate(key_vk_with_unicode(0x41, 'a', 0)) + .as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + .. + }] + )); + + let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\\x1b]11;rgb:1111/2222/3333\x1b\\" + .chars() + .map(key_char) + .flat_map(|record| translator.translate(record)) + .collect::<Vec<_>>(); + let mut shell = crate::client::shell::ClientShellState::new( + crate::client::shell::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + let outcome = shell.handle_client_events(&events); + assert_eq!( + outcome.requests, + vec![ + ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::DefaultColor { + kind: ClientHostDefaultColorKind::Foreground, + color: ClientHostColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }, + }, + }, + ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::DefaultColor { + kind: ClientHostDefaultColorKind::Background, + color: ClientHostColor { + r: 0x11, + g: 0x22, + b: 0x33, + }, + }, + }, + ] + ); + assert!(outcome.actions.is_empty()); + } + + #[cfg(windows)] + #[test] + fn ctrl_bracket_precedes_next_key_while_host_color_reply_is_pending() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + assert!(translator + .translate(key_vk_with_utf16_mods(0xdb, 0x1b, 0x0008)) + .is_empty()); + let events = translator.translate(key_vk_with_unicode(0x0d, '\r', 0)); + assert!(matches!( + events.as_slice(), + [ + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + .. + }, + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + .. + } + ] + )); + } + + #[cfg(windows)] + #[test] + fn ctrl_bracket_idle_does_not_cancel_later_host_color_reply() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + assert!(translator + .translate(key_vk_with_utf16_mods(0xdb, 0x1b, 0x0008)) + .is_empty()); + assert!(matches!( + translator.idle().as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + .. + }] + )); + assert!(translator.translate(key_char('\x1b')).is_empty()); + assert!(translator.idle().is_empty()); + let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\" + .chars() + .map(key_char) + .flat_map(|record| translator.translate(record)) + .collect::<Vec<_>>(); + assert!(matches!( + events.as_slice(), + [crate::protocol::ClientInputEvent::HostDefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Foreground, + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn serialized_host_reply_escape_survives_intervening_key() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + for record in win32_input_mode_encoded_raw_bytes(b"\x1b") { + assert!(translator.translate(record).is_empty()); + } + let key_events = translator.translate(key_vk_with_unicode(0x0d, '\r', 0)); + assert!(matches!( + key_events.as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + .. + }] + )); + let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\" + .chars() + .map(key_char) + .flat_map(|record| translator.translate(record)) + .collect::<Vec<_>>(); + assert!(matches!( + events.as_slice(), + [crate::protocol::ClientInputEvent::HostDefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Foreground, + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn expired_host_color_reply_tail_does_not_swallow_semantic_key() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::ZERO); + for record in "\x1b]11;rgb:1111/".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + assert!(matches!( + translator + .translate(key_vk_with_unicode(0x0d, '\r', 0)) + .as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + .. + }] + )); + for record in "2222/3333\x1b\\".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(matches!( + translator + .translate(key_vk_with_unicode(0x41, 'a', 0)) + .as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn unanswered_host_color_query_stops_holding_escape() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_millis(1)); + std::thread::sleep(std::time::Duration::from_millis(10)); + assert!(translator.translate(key_char('\x1b')).is_empty()); + assert!(matches!( + translator.idle().as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + .. + }] + )); + } + fn key_char(ch: char) -> WindowsInputRecord { WindowsInputRecord::Key(WindowsKeyRecord { key_down: true, @@ -2083,6 +2355,11 @@ mod tests { }, ] { let mut translator = WindowsInputTranslator::default(); + #[cfg(windows)] + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); assert!(translator .translate(WindowsInputRecord::Key(record)) .is_empty()); diff --git a/src/client/mod.rs b/src/client/mod.rs index b78517ac..d55e4c48 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -70,7 +70,7 @@ use terminal_geometry::{ }; use terminal_geometry::{ host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size, - query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme, + query_host_terminal_theme, resize_poll_loop, }; #[cfg(unix)] use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; @@ -517,8 +517,7 @@ async fn run_client_loop( let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); // Spawn the stdin reader thread. - let will_query_host_terminal_theme = - state.attach_escape.is_none() && should_query_host_terminal_theme(); + let will_query_host_terminal_theme = state.attach_escape.is_none(); let host_theme_query_pending = Arc::new(AtomicU32::new(0)); let stdin_host_theme_query_pending = host_theme_query_pending.clone(); // Terminals behind ConPTY report no pixel size through the ioctl, so ask the @@ -555,9 +554,9 @@ async fn run_client_loop( ); }); + #[cfg(unix)] if will_query_host_terminal_theme { query_host_terminal_theme(); - #[cfg(not(windows))] if state.shell.is_some() { query_host_terminal_appearance(); } @@ -1191,9 +1190,9 @@ async fn run_client_loop( cell_height_px, pixel_geometry_exact, ) => { - // Palette updates through OSC do not necessarily produce a color-scheme - // notification. Re-query on redraw, including SIGWINCH without a resize, - // so desktop theme switchers can refresh the existing panes in place. + // On Unix, palette changes may lack a color-scheme notification. + // Re-query on redraw, including SIGWINCH without a resize. + #[cfg(unix)] if will_query_host_terminal_theme { host_theme_query_pending.fetch_add(1, Ordering::AcqRel); query_host_terminal_theme(); diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index d0eafdf6..5e1da58c 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -751,6 +751,10 @@ pub(super) fn finish_client_shell_input( } continue; } + if endpoints.active_surface_available() { + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + continue; } // Host focus belongs to a pending target even when the source has gone offline or has // already had its surface revoked. Route it before the ordinary source-online gate. @@ -791,3 +795,109 @@ pub(super) fn finish_client_shell_input( } Ok(false) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_color_reaches_server_before_first_snapshot() { + #[derive(Clone)] + struct Capture(std::sync::Arc<std::sync::Mutex<Vec<ClientMessage>>>); + + impl endpoint::EndpointTransport for Capture { + fn send(&mut self, message: &ClientMessage) -> std::io::Result<()> { + self.0.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = endpoint::EndpointRegistry::new( + Capture(sent.clone()), + 1, + endpoint::EndpointNegotiation::new(Vec::new(), Vec::new()), + ); + let mut state = ClientState::test_new(); + assert!(!state + .shell + .as_ref() + .unwrap() + .endpoint_is_online(&endpoint::ClientEndpointId::Local)); + let outcome = state.shell.as_mut().unwrap().handle_raw_events(vec![ + crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 0x11, + g: 0x22, + b: 0x33, + }, + }, + ]); + let mut pending_activation = None; + let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); + let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); + let mut scheduled_activation = None; + finish_client_shell_input( + &mut state, + outcome, + None, + &mut endpoints, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &mut scheduled_activation, + ) + .unwrap(); + + assert_eq!( + *sent.lock().unwrap(), + vec![ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Background, + color: crate::protocol::ClientHostColor { + r: 0x11, + g: 0x22, + b: 0x33, + }, + }, + }] + ); + + let local = endpoint::ClientEndpointId::Local; + assert!(endpoints.set_surface_active(&local, false)); + let outcome = state.shell.as_mut().unwrap().handle_raw_events(vec![ + crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Foreground, + color: crate::terminal_theme::RgbColor { r: 4, g: 5, b: 6 }, + }, + ]); + finish_client_shell_input( + &mut state, + outcome, + None, + &mut endpoints, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &mut scheduled_activation, + ) + .unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + + assert!(endpoints.set_surface_active(&local, true)); + state.replay_host_theme(&mut endpoints, &local); + let messages = sent.lock().unwrap(); + assert_eq!(messages.len(), 3); + assert_eq!(messages[1], messages[0]); + assert_eq!( + messages[2], + ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Foreground, + color: crate::protocol::ClientHostColor { r: 4, g: 5, b: 6 }, + }, + } + ); + } +} diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs index f15141bb..02d6d05a 100644 --- a/src/client/terminal_geometry.rs +++ b/src/client/terminal_geometry.rs @@ -192,10 +192,6 @@ pub(super) fn query_host_terminal_theme() { let _ = write_host_terminal_theme_query(io::stdout()); } -pub(super) fn should_query_host_terminal_theme() -> bool { - !cfg!(windows) -} - pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { let query = crate::terminal_theme::host_terminal_theme_query_sequence( crate::platform::should_query_host_terminal_palette(), diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 9c84985a..249e29ad 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -20,7 +20,6 @@ use crossterm::execute; use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; use super::frame_output::clear_received_kitty_graphics; -use super::terminal_geometry::should_query_host_terminal_theme; // --------------------------------------------------------------------------- // Terminal setup / restore @@ -86,7 +85,7 @@ pub(super) fn setup_terminal_with_capabilities( } (active, buffered_input) } else { - if should_query_host_terminal_theme() { + if should_enable_host_color_scheme_reports(true) { write_host_color_scheme_report_mode(&mut io::stdout(), false)?; } set_mouse_capture(mouse_capture, false)?; @@ -128,7 +127,7 @@ pub(super) fn setup_terminal_with_capabilities( } pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { - enable_client_protocols && should_query_host_terminal_theme() + enable_client_protocols && !cfg!(windows) } /// Guard that restores the terminal when dropped. diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 2fcac6eb..035d00d3 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -439,11 +439,6 @@ fn color_scheme_change_event_requests_host_theme_query() { } #[test] -fn host_terminal_theme_query_is_disabled_on_windows() { - assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); -} - -#[test] fn write_host_cell_size_query_emits_xtwinops_request() { let mut output = Vec::new(); write_host_cell_size_query(&mut output).unwrap(); diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index e7fb2743..6d49592d 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -147,6 +147,10 @@ pub enum ClientInputEvent { }, FocusGained, FocusLost, + HostDefaultColor { + kind: ClientHostDefaultColorKind, + color: ClientHostColor, + }, } /// Pane-domain input after the client has classified and consumed shell actions. @@ -458,6 +462,19 @@ impl ClientInputEvent { Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()), Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained, Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost, + Self::HostDefaultColor { kind, color } => { + crate::raw_input::RawInputEvent::HostDefaultColor { + kind: match kind { + ClientHostDefaultColorKind::Foreground => { + crate::terminal_theme::DefaultColorKind::Foreground + } + ClientHostDefaultColorKind::Background => { + crate::terminal_theme::DefaultColorKind::Background + } + }, + color: (*color).into(), + } + } } } } diff --git a/src/raw_input.rs b/src/raw_input.rs index 2694912c..1af4e894 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -1,4 +1,6 @@ use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +#[cfg(windows)] +use std::time::{Duration, Instant}; /// Parse raw terminal input bytes into a list of `RawInputEvent`s. /// @@ -84,6 +86,11 @@ impl RawInputFramer { } } + #[cfg(windows)] + pub(crate) fn host_default_color_query_sent(&mut self, escape_grace: Duration) { + self.byte_framer.host_default_color_query_sent(escape_grace); + } + pub(crate) fn push(&mut self, data: &[u8]) -> Vec<RawInputEvent> { Self::events_from_chunks(self.byte_framer.push(data)) } @@ -93,6 +100,21 @@ impl RawInputFramer { self.byte_framer.has_pending_input() } + #[cfg(windows)] + pub(crate) fn holds_host_default_color_escape(&self) -> bool { + self.byte_framer.buffer.as_slice() == [ESC] + && self.byte_framer.host_color_replies_awaited > 0 + && self + .byte_framer + .host_default_color_query_deadline + .is_some_and(|deadline| Instant::now() < deadline) + } + + #[cfg(all(test, not(windows)))] + pub(crate) fn holds_host_default_color_escape(&self) -> bool { + false + } + #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.byte_framer.has_pending_bracketed_paste() @@ -107,9 +129,21 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.flush_timeout()) } + #[cfg(any(windows, test))] + pub(crate) fn flush_keyboard_escape(&mut self) -> Vec<RawInputEvent> { + if self.byte_framer.buffer.as_slice() == [ESC] { + self.byte_framer.lone_escape_recently_flushed = true; + Self::events_from_chunks(vec![std::mem::take(&mut self.byte_framer.buffer)]) + } else { + self.flush_timeout() + } + } + /// Semantic input ends mouse recovery, unlike another idle interval. #[cfg(any(windows, test))] pub(crate) fn flush_interrupted(&mut self) -> Vec<RawInputEvent> { + #[cfg(windows)] + self.byte_framer.flush_lone_escape_on_interruption(); let mut chunks = self.byte_framer.flush_timeout(); self.byte_framer.timed_out_mouse_prefix = None; chunks.extend(self.byte_framer.drain_available_chunks()); @@ -144,6 +178,14 @@ pub(crate) struct RawInputByteFramer { timed_out_mouse_prefix: Option<Vec<u8>>, lone_escape_recently_flushed: bool, host_color_replies_awaited: u16, + #[cfg(windows)] + host_default_color_query_issued: bool, + #[cfg(windows)] + host_default_color_query_deadline: Option<Instant>, + #[cfg(windows)] + host_color_tail_deadline: Option<Instant>, + #[cfg(windows)] + host_color_tail_split_st: bool, host_cell_size_replies_awaited: u16, host_appearance_reply_awaited: bool, held_pending_host_reply_esc: bool, @@ -181,12 +223,29 @@ impl RawInputByteFramer { /// at its ESC introducer stitches back together instead of leaking (#549). /// Overlapping queries each add their own replies to the window. pub(crate) fn host_color_query_sent(&mut self) { - self.host_color_replies_awaited = self - .host_color_replies_awaited - .saturating_add(HOST_COLOR_QUERY_REPLIES); + self.host_color_query_sent_with_replies(HOST_COLOR_QUERY_REPLIES); + } + + #[cfg(windows)] + pub(crate) fn host_default_color_query_sent(&mut self, reply_window: Duration) { + self.host_color_query_sent_with_replies(2); + self.host_default_color_query_issued = true; + self.host_default_color_query_deadline = Some(Instant::now() + reply_window); + } + + fn host_color_query_sent_with_replies(&mut self, replies: u16) { + self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_add(replies); self.held_pending_host_reply_esc = false; } + #[cfg(windows)] + fn flush_lone_escape_on_interruption(&mut self) { + if self.buffer.as_slice() == [ESC] { + self.held_pending_host_reply_esc = true; + self.host_default_color_query_deadline = None; + } + } + fn host_appearance_query_sent(&mut self) { self.host_appearance_reply_awaited = true; self.held_pending_host_reply_esc = false; @@ -252,6 +311,16 @@ impl RawInputByteFramer { pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> { let mut chunks = self.drain_available_chunks(); + #[cfg(windows)] + let host_color_query_expired = self + .host_default_color_query_deadline + .is_some_and(|deadline| Instant::now() >= deadline); + #[cfg(windows)] + if host_color_query_expired { + self.host_default_color_query_deadline = None; + self.host_color_replies_awaited = 0; + } + // Idle is not evidence that a mouse report has ended. The continuation // stays bounded and is released if it cannot complete a valid report. if self.timed_out_mouse_prefix.is_some() { @@ -262,6 +331,10 @@ impl RawInputByteFramer { if family == ControlStringFamily::HostReplyCsi { return chunks; } + #[cfg(windows)] + if self.host_color_tail_deadline.is_some() { + return chunks; + } let keep_split_st = self.buffer.last() == Some(&ESC); let keep_discarding = plausible_control_string_tail(family, &self.buffer); self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len()); @@ -323,6 +396,32 @@ impl RawInputByteFramer { return chunks; } + #[cfg(windows)] + if self.host_default_color_query_issued + && is_incomplete_host_default_color_prefix(&self.buffer) + { + if self.host_default_color_query_deadline.is_some() + && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES + { + return chunks; + } + if host_color_query_expired && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES { + self.discard_until = Some(ControlStringFamily::Osc); + let split_st = self.buffer.last() == Some(&ESC); + self.discarded_tail_bytes = self.buffer.len() - usize::from(split_st); + self.host_color_tail_deadline = + Some(Instant::now() + HOST_COLOR_TAIL_RECOVERY_GRACE); + self.host_color_tail_split_st = split_st; + self.buffer.clear(); + if split_st { + self.buffer.push(ESC); + } + return chunks; + } + self.buffer.clear(); + return chunks; + } + if starts_with_incomplete_default_color_response(&self.buffer) { tracing::trace!( len = self.buffer.len(), @@ -394,6 +493,13 @@ impl RawInputByteFramer { } if self.buffer.as_slice() == [ESC] { + #[cfg(windows)] + if self.host_color_replies_awaited > 0 + && self.host_default_color_query_deadline.is_some() + { + // Native console reply fragments can arrive across several idle polls. + return chunks; + } if self.awaiting_host_reply() && !self.held_pending_host_reply_esc { self.held_pending_host_reply_esc = true; tracing::trace!("holding lone escape one flush while awaiting host reply"); @@ -401,6 +507,10 @@ impl RawInputByteFramer { } // No continuation arrived; give up the window so Escape is not delayed again. self.host_color_replies_awaited = 0; + #[cfg(windows)] + { + self.host_default_color_query_deadline = None; + } self.host_cell_size_replies_awaited = 0; self.host_appearance_reply_awaited = false; self.held_pending_host_reply_esc = false; @@ -470,6 +580,57 @@ impl RawInputByteFramer { } if let Some(family) = self.discard_until { + #[cfg(windows)] + if let Some(deadline) = self.host_color_tail_deadline { + if self.host_color_tail_split_st + && self.buffer.len() > 1 + && self.buffer[1] != b'\\' + { + self.buffer.drain(..1); + self.end_host_color_tail_recovery(); + continue; + } + let terminator_len = osc_string_terminator(&self.buffer); + if let Some(new_escape) = self + .buffer + .windows(2) + .position(|pair| pair[0] == ESC && pair[1] != b'\\') + { + if terminator_len.is_none_or(|len| new_escape < len) { + self.buffer.drain(..new_escape); + self.end_host_color_tail_recovery(); + continue; + } + } + let body_len = terminator_len + .map(|len| len - if self.buffer[len - 1] == 0x07 { 1 } else { 2 }) + .unwrap_or(self.buffer.len()); + if let Some(invalid) = self.buffer[..body_len] + .iter() + .position(|byte| !plausible_host_color_tail_byte(*byte)) + { + self.buffer.drain(..invalid); + self.end_host_color_tail_recovery(); + continue; + } + if let Some(terminator_len) = terminator_len { + if self.discarded_tail_bytes + terminator_len + <= MAX_DISCARDED_CONTROL_TAIL_BYTES + { + self.buffer.drain(..terminator_len); + self.end_host_color_tail_recovery(); + continue; + } + } + if Instant::now() >= deadline + || self.discarded_tail_bytes + self.buffer.len() + >= MAX_DISCARDED_CONTROL_TAIL_BYTES + { + self.end_host_color_tail_recovery(); + continue; + } + break; + } if family == ControlStringFamily::HostReplyCsi { if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) { @@ -533,9 +694,42 @@ impl RawInputByteFramer { chunks } + + #[cfg(windows)] + fn end_host_color_tail_recovery(&mut self) { + self.discard_until = None; + self.discarded_tail_bytes = 0; + self.host_color_tail_deadline = None; + self.host_color_tail_split_st = false; + } } const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128; +#[cfg(windows)] +const HOST_COLOR_TAIL_RECOVERY_GRACE: Duration = Duration::from_millis(100); + +#[cfg(windows)] +fn plausible_host_color_tail_byte(byte: u8) -> bool { + byte.is_ascii_hexdigit() + || matches!( + byte, + b';' | b':' + | b'/' + | b'#' + | b'?' + | b'.' + | b'_' + | b'-' + | b'+' + | b'r' + | b'g' + | b'b' + | b'R' + | b'G' + | b'B' + | ESC + ) +} fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> bool { match family { @@ -728,6 +922,20 @@ fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool { ) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;")) } +#[cfg(windows)] +fn is_incomplete_host_default_color_prefix(buffer: &[u8]) -> bool { + buffer.starts_with(b"\x1b]") + && matches!( + control_string(buffer), + Some(ControlString::Incomplete { + family: ControlStringFamily::Osc + }) + ) + && [b"\x1b]10;".as_slice(), b"\x1b]11;".as_slice()] + .iter() + .any(|prefix| prefix.starts_with(buffer) || buffer.starts_with(prefix)) +} + fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool { buffer.starts_with(b"\x1b[?") && (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer) @@ -2537,6 +2745,121 @@ mod tests { assert!(framer.flush_timeout().is_empty()); } + #[cfg(windows)] + #[test] + fn windows_host_color_prefix_respects_query_deadline() { + let mut framer = RawInputFramer::for_host_input(); + framer.host_default_color_query_sent(Duration::from_secs(1)); + assert!(framer.push(b"\x1b]10").is_empty()); + for _ in 0..3 { + assert!(framer.flush_timeout().is_empty()); + } + assert!(framer.push(b";rgb:aaaa/").is_empty()); + for _ in 0..3 { + assert!(framer.flush_timeout().is_empty()); + } + assert!(matches!( + framer.push(b"bbbb/cccc\x1b\\").as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + }] + )); + + let mut expired = RawInputFramer::for_host_input(); + expired.host_default_color_query_sent(Duration::ZERO); + assert!(expired.push(b"\x1b]11;").is_empty()); + assert!(expired.flush_timeout().is_empty()); + assert!(matches!( + expired.push(b"x").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x') + )); + assert!(matches!( + expired.push(b"\x1b]11;rgb:1111/2222/3333\x1b\\").as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Background, + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn windows_expired_host_color_reply_tail_does_not_reach_pane() { + let mut framer = RawInputFramer::for_host_input(); + framer.host_default_color_query_sent(Duration::ZERO); + assert!(framer.push(b"\x1b]11;rgb:1111/").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"2222/").is_empty()); + for _ in 0..3 { + assert!(framer.flush_timeout().is_empty()); + } + assert!(matches!( + framer.push(b"3333\x1b\\x").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x') + )); + + let mut expired = RawInputFramer::for_host_input(); + expired.host_default_color_query_sent(Duration::ZERO); + assert!(expired.push(b"\x1b]10;rgb:aaaa/").is_empty()); + assert!(expired.flush_timeout().is_empty()); + expired.byte_framer.host_color_tail_deadline = Some(Instant::now()); + assert!(expired.flush_timeout().is_empty()); + assert!(matches!( + expired.push(b"y").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('y') + )); + + let mut raw_keyboard = RawInputFramer::for_host_input(); + raw_keyboard.host_default_color_query_sent(Duration::ZERO); + assert!(raw_keyboard.push(b"\x1b]11;rgb:1111/").is_empty()); + assert!(raw_keyboard.flush_timeout().is_empty()); + assert!(raw_keyboard.push(b"a").is_empty()); + raw_keyboard.byte_framer.host_color_tail_deadline = Some(Instant::now()); + assert!(matches!( + raw_keyboard.flush_timeout().as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('a') + )); + + let mut split_st = RawInputFramer::for_host_input(); + split_st.host_default_color_query_sent(Duration::ZERO); + assert!(split_st.push(b"\x1b]11;rgb:1111/2222/3333\x1b").is_empty()); + assert!(split_st.flush_timeout().is_empty()); + assert!(matches!( + split_st.push(b"\\x").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x') + )); + + let mut abandoned_st = RawInputFramer::for_host_input(); + abandoned_st.host_default_color_query_sent(Duration::ZERO); + assert!(abandoned_st + .push(b"\x1b]11;rgb:1111/2222/3333\x1b") + .is_empty()); + assert!(abandoned_st.flush_timeout().is_empty()); + assert!(matches!( + abandoned_st + .push(b"\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\") + .as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + }] + )); + + let mut next_reply = RawInputFramer::for_host_input(); + next_reply.host_default_color_query_sent(Duration::ZERO); + assert!(next_reply.push(b"\x1b]11;rgb:1111/").is_empty()); + assert!(next_reply.flush_timeout().is_empty()); + assert!(next_reply.push(b"\x1b").is_empty()); + assert!(matches!( + next_reply.push(b"]10;rgb:aaaa/bbbb/cccc\x1b\\").as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + }] + )); + } + #[test] fn holds_lone_escape_and_stitches_split_host_color_reply() { let mut framer = RawInputByteFramer::default(); |
