From 3825c0c30099dbe4cf19627b0525ce09f6580995 Mon Sep 17 00:00:00 2001 From: akbash Date: Thu, 6 Aug 2026 17:38:20 +0300 Subject: fix(theme): refresh host appearance on focus (#2417) * fix(theme): refresh host appearance on focus refs #2416 * fix(input): preserve split appearance replies refs #2416 * fix(input): retain fragmented appearance reports refs #2416 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Can Celik --- src/raw_input.rs | 170 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 6 deletions(-) (limited to 'src/raw_input.rs') diff --git a/src/raw_input.rs b/src/raw_input.rs index 12bcba4d..510768fc 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(); -- cgit v1.2.3