diff --git a/src/app/runtime.rs b/src/app/runtime.rs index e05b95998e..4942038106 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -233,6 +233,8 @@ impl App { changes_view } crate::raw_input::RawInputEvent::OuterFocusGained => { + #[cfg(not(windows))] + self.query_host_terminal_appearance(); self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); if self.state.redraw_on_focus_gained { self.request_repaint(); diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index 32716cedbd..6bf06685df 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -1,6 +1,15 @@ use super::App; impl App { + #[cfg(not(windows))] + pub(super) fn query_host_terminal_appearance(&self) { + use std::io::Write; + + let _ = std::io::stdout() + .write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes()); + let _ = std::io::stdout().flush(); + } + pub(super) fn query_host_terminal_theme(&self) { use std::io::Write; diff --git a/src/client/input.rs b/src/client/input.rs index 9e0d300ebd..4dc26eae33 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -77,6 +77,7 @@ fn unix_stdin_reader_loop( if host_color_query_sent { framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); + framer.enable_host_appearance_query_on_focus(); } if host_cell_size_query_sent { framer.host_cell_size_query_sent(); diff --git a/src/client/mod.rs b/src/client/mod.rs index fd92d18868..b153dd93ce 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1442,6 +1442,9 @@ async fn run_client_loop( ) { state.request_repaint(); } + if crate::raw_input::events_require_host_terminal_appearance_query(&events) { + query_host_terminal_appearance(); + } if crate::raw_input::events_require_host_terminal_theme_query(&events) { query_host_terminal_theme(); } @@ -2281,6 +2284,17 @@ fn resize_poll_loop( // Logging // --------------------------------------------------------------------------- +#[cfg(any(not(windows), test))] +fn query_host_terminal_appearance() { + let _ = write_host_terminal_appearance_query(io::stdout()); +} + +#[cfg(any(not(windows), test))] +fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; + writer.flush() +} + /// Initialize logging for the client process. fn query_host_terminal_theme() { let _ = write_host_terminal_theme_query(io::stdout()); @@ -2672,6 +2686,13 @@ mod tests { assert!(!text.contains("d=A")); } + #[test] + fn write_host_terminal_appearance_query_emits_mode_2031_query() { + let mut output = Vec::new(); + write_host_terminal_appearance_query(&mut output).unwrap(); + assert_eq!(output, b"\x1b[?996n"); + } + #[test] fn write_host_terminal_theme_query_emits_osc_queries() { let mut output = Vec::new(); @@ -2680,6 +2701,10 @@ mod tests { output, crate::terminal_theme::host_terminal_theme_query_sequence().as_bytes() ); + assert!(!output + .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) + .any(|window| window + == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())); } #[test] diff --git a/src/raw_input.rs b/src/raw_input.rs index 12bcba4d89..510768fc92 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -172,6 +172,11 @@ impl RawInputFramer { self.byte_framer.enable_host_color_scheme_change_tracking(); } + #[cfg(any(not(windows), test))] + pub(crate) fn enable_host_appearance_query_on_focus(&mut self) { + self.byte_framer.enable_host_appearance_query_on_focus(); + } + pub(crate) fn has_pending_input(&self) -> bool { self.byte_framer.has_pending_input() } @@ -216,8 +221,10 @@ pub(crate) struct RawInputByteFramer { lone_escape_recently_flushed: bool, host_color_replies_awaited: u16, host_cell_size_replies_awaited: u16, + host_appearance_reply_awaited: bool, held_pending_host_reply_esc: bool, host_color_scheme_change_tracking: bool, + host_appearance_query_on_focus: bool, split_coalesced_escape: bool, } @@ -252,6 +259,11 @@ impl RawInputByteFramer { self.held_pending_host_reply_esc = false; } + fn host_appearance_query_sent(&mut self) { + self.host_appearance_reply_awaited = true; + self.held_pending_host_reply_esc = false; + } + /// Same hold window as `host_color_query_sent`, for the XTWINOPS cell size /// reply. Only the Unix client sends this query. #[cfg(any(unix, test))] @@ -261,13 +273,22 @@ impl RawInputByteFramer { } fn awaiting_host_reply(&self) -> bool { - self.host_color_replies_awaited > 0 || self.host_cell_size_replies_awaited > 0 + self.host_color_replies_awaited > 0 + || self.host_cell_size_replies_awaited > 0 + || self.host_appearance_reply_awaited } pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) { self.host_color_scheme_change_tracking = true; } + /// Arm the bounded host-reply window when focus gain will emit an appearance query. + /// If the write or reply fails, a lone Escape is delayed for only one extra flush. + #[cfg(any(not(windows), test))] + pub(crate) fn enable_host_appearance_query_on_focus(&mut self) { + self.host_appearance_query_on_focus = true; + } + pub(crate) fn has_pending_input(&self) -> bool { !self.buffer.is_empty() } @@ -365,13 +386,16 @@ impl RawInputByteFramer { return chunks; } - if self.host_cell_size_replies_awaited > 0 && self.buffer.as_slice() == b"\x1b[" { + if (self.host_cell_size_replies_awaited > 0 || self.host_appearance_reply_awaited) + && self.buffer.as_slice() == b"\x1b[" + { if !self.held_pending_host_reply_esc { self.held_pending_host_reply_esc = true; - tracing::trace!("holding incomplete cell size reply one flush"); + tracing::trace!("holding incomplete host CSI reply one flush"); return chunks; } self.host_cell_size_replies_awaited = 0; + self.host_appearance_reply_awaited = false; self.held_pending_host_reply_esc = false; } @@ -391,10 +415,20 @@ impl RawInputByteFramer { } if starts_with_incomplete_host_color_scheme_report(&self.buffer) { + if self.host_appearance_reply_awaited && !self.held_pending_host_reply_esc { + self.held_pending_host_reply_esc = true; + tracing::trace!( + len = self.buffer.len(), + "holding incomplete host color scheme report one flush" + ); + return chunks; + } tracing::debug!( len = self.buffer.len(), "discarding incomplete host color scheme report after input timeout" ); + self.host_appearance_reply_awaited = false; + self.held_pending_host_reply_esc = false; self.discard_until = Some(ControlStringFamily::HostReplyCsi); self.discarded_tail_bytes = 0; self.buffer.clear(); @@ -423,6 +457,7 @@ impl RawInputByteFramer { // No continuation arrived; give up the window so Escape is not delayed again. self.host_color_replies_awaited = 0; self.host_cell_size_replies_awaited = 0; + self.host_appearance_reply_awaited = false; self.held_pending_host_reply_esc = false; tracing::warn!( bytes = ?self.buffer, @@ -522,10 +557,15 @@ impl RawInputByteFramer { } else if matches!(event, RawInputEvent::HostCellSizeReport { .. }) { self.host_cell_size_replies_awaited = self.host_cell_size_replies_awaited.saturating_sub(1); - } else if self.host_color_scheme_change_tracking - && matches!(event, RawInputEvent::HostColorSchemeChanged(_)) + } else if self.host_appearance_query_on_focus + && matches!(event, RawInputEvent::OuterFocusGained) { - self.host_color_query_sent(); + self.host_appearance_query_sent(); + } else if matches!(event, RawInputEvent::HostColorSchemeChanged(_)) { + self.host_appearance_reply_awaited = false; + if self.host_color_scheme_change_tracking { + self.host_color_query_sent(); + } } self.held_pending_host_reply_esc = false; chunks.push(self.buffer[..consumed].to_vec()); @@ -579,6 +619,13 @@ pub(crate) fn events_require_host_surface_redraw( .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) } +#[cfg(any(not(windows), test))] +pub(crate) fn events_require_host_terminal_appearance_query(events: &[RawInputEvent]) -> bool { + events + .iter() + .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) +} + #[cfg(any(not(windows), test))] pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) -> bool { events @@ -604,6 +651,8 @@ pub fn spawn_input_reader() -> mpsc::Receiver { let mut framer = RawInputFramer::for_host_input(); framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); + #[cfg(not(windows))] + framer.enable_host_appearance_query_on_focus(); let mut pending_palette = Vec::new(); loop { @@ -1544,6 +1593,20 @@ mod tests { assert!(!events_require_host_surface_redraw(&events, true)); } + #[test] + fn outer_focus_gained_requests_host_appearance_query() { + let gained = parse_raw_input_bytes_sync(b"\x1b[I"); + let lost = parse_raw_input_bytes_sync(b"\x1b[O"); + let scheme_report = parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + + assert!(events_require_host_terminal_appearance_query(&gained)); + assert!(!events_require_host_terminal_appearance_query(&lost)); + assert!(!events_require_host_terminal_appearance_query( + &scheme_report + )); + assert!(events_require_host_terminal_theme_query(&scheme_report)); + } + #[test] fn parses_ghostty_color_scheme_reports() { for bytes in [ @@ -2863,6 +2926,101 @@ mod tests { assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); } + #[test] + fn opted_in_byte_framer_rearms_after_outer_focus_gained() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_color_scheme_change_tracking(); + framer.enable_host_appearance_query_on_focus(); + + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(b"[?997;2n"), + vec![GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.to_vec()] + ); + } + + #[test] + fn opted_in_byte_framer_reassembles_appearance_reply_split_after_csi() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_color_scheme_change_tracking(); + framer.enable_host_appearance_query_on_focus(); + + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b[").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(b"?997;2n"), + vec![GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.to_vec()] + ); + } + + #[test] + fn opted_in_byte_framer_reassembles_delayed_appearance_reply() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_color_scheme_change_tracking(); + framer.enable_host_appearance_query_on_focus(); + + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b[?997;").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(b"2n"), + vec![GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.to_vec()] + ); + } + + #[test] + fn timed_out_appearance_reply_preserves_pending_color_reply_window() { + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + framer.enable_host_appearance_query_on_focus(); + + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b[?997;").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"2n").is_empty()); + + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(b"]10;rgb:aaaa/bbbb/cccc\x1b\\"), + vec![b"\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\".to_vec()] + ); + } + + #[test] + fn disabled_focus_query_does_not_rearm_byte_framer() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_color_scheme_change_tracking(); + + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); + } + + #[test] + fn focus_query_policy_does_not_delay_plain_escape_without_focus() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_appearance_query_on_focus(); + + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); + } + + #[test] + fn focus_query_without_reply_holds_escape_for_only_one_flush() { + let mut framer = RawInputByteFramer::default(); + framer.enable_host_appearance_query_on_focus(); + + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().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(); diff --git a/src/terminal_theme.rs b/src/terminal_theme.rs index ffe78a95b1..3f04cc4c24 100644 --- a/src/terminal_theme.rs +++ b/src/terminal_theme.rs @@ -55,6 +55,8 @@ pub enum DefaultColorKind { } pub const HOST_COLOR_QUERY_SEQUENCE: &str = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\"; +#[cfg(any(not(windows), test))] +pub const HOST_COLOR_SCHEME_QUERY_SEQUENCE: &str = "\x1b[?996n"; pub const HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE: &str = "\x1b[?2031h"; pub const HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE: &str = "\x1b[?2031l";