From a5c69beabfc82d9c3f9563eb821139b2e0f3e14f Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 19 Aug 2026 04:43:28 +0300 Subject: fix(perf): eliminate redundant terminal wake work (#2962) * fix(perf): eliminate redundant terminal wake work * fix(perf): harden release benchmark gate * test(perf): strengthen performance guardrails --- src/app/actions.rs | 5 +- src/app/api_helpers.rs | 5 +- src/app/input/mouse.rs | 4 +- src/app/input/terminal.rs | 13 ++---- src/app/mod.rs | 2 + src/app/state.rs | 23 +++++++++- src/ghostty/bindings.rs | 3 +- src/ghostty/mod.rs | 15 ++++++ src/input/mod.rs | 6 ++- src/input/model.rs | 3 ++ src/pane.rs | 49 +++++++++++++------- src/pane/terminal.rs | 93 +++++++++++++++++++++++++++++++++---- src/render_signal.rs | 104 +++++++++++++++++++++++++++--------------- src/server/headless.rs | 66 +++++++++++++++++---------- src/server/terminal_attach.rs | 6 +-- src/terminal/runtime.rs | 26 +++++++++-- 16 files changed, 299 insertions(+), 124 deletions(-) (limited to 'src') diff --git a/src/app/actions.rs b/src/app/actions.rs index d6e264f5..a45687b4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2175,10 +2175,7 @@ impl AppState { else { return false; }; - if rt - .input_state() - .is_some_and(crate::pane::InputState::mouse_reporting_enabled) - { + if rt.mouse_reporting_enabled() { return false; } diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 95224b9f..a224938d 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -23,10 +23,7 @@ fn normalize_api_key_alias(key: &str) -> &str { } pub(super) fn encode_api_text(runtime: &crate::terminal::TerminalRuntime, text: &str) -> Vec { - let bracketed = runtime - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false); + let bracketed = runtime.bracketed_paste_enabled(); if bracketed { format!("\x1b[200~{text}\x1b[201~").into_bytes() } else { diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 3066c2a4..3c6c86f4 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1761,9 +1761,7 @@ impl AppState { let Some(host) = self.host_mouse_pixels else { return Some(cell); }; - let wants_pixels = runtime.input_state().is_some_and(|state| { - state.mouse_protocol_encoding == crate::input::MouseProtocolEncoding::SgrPixels - }); + let wants_pixels = runtime.sgr_pixel_mouse_enabled(); if !wants_pixels { return Some(cell); } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index e5b40a74..6b3d3537 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -154,8 +154,8 @@ impl App { if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) && key_event.modifiers.is_empty() { - if let Some(input_state) = rt.input_state() { - if input_state.plain_page_keys_use_host_scrollback() { + if let Some(host_scroll) = rt.plain_page_keys_use_host_scrollback() { + if host_scroll { if key_event.kind == crossterm::event::KeyEventKind::Release { return None; } @@ -282,14 +282,7 @@ impl App { None }; - runtime.is_some_and(|runtime| { - let protocol = runtime.keyboard_protocol(); - protocol.reports_all_keys() - || (protocol.reports_event_types() - && runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)) - }) + runtime.is_some_and(crate::terminal::TerminalRuntime::keyboard_report_all_requested) } fn terminal_input_runtime( diff --git a/src/app/mod.rs b/src/app/mod.rs index 75568990..85ad4fa3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1065,6 +1065,8 @@ impl App { } let now = Instant::now(); + self.render_dirty + .set_immediate_pty_sources(self.state.app_surface_pane_ids()); self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; diff --git a/src/app/state.rs b/src/app/state.rs index b8205451..d9dcfc9d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1614,6 +1614,26 @@ impl AppState { section == SettingsSection::Integrations && self.integration_updates_available() } + pub(crate) fn app_surface_pane_ids(&self) -> std::collections::HashSet { + let mut pane_ids = std::collections::HashSet::new(); + if let Some(popup) = &self.popup_pane { + pane_ids.insert(popup.pane_id); + } + let Some(tab) = self + .active + .and_then(|ws_idx| self.workspaces.get(ws_idx)) + .and_then(crate::workspace::Workspace::active_tab) + else { + return pane_ids; + }; + if tab.zoomed { + pane_ids.insert(tab.layout.focused()); + } else { + pane_ids.extend(tab.panes.keys().copied()); + } + pane_ids + } + pub(crate) fn focused_pane_requests_mouse_capture_from( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, @@ -1622,8 +1642,7 @@ impl AppState { && self .active .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) - .and_then(crate::terminal::TerminalRuntime::input_state) - .is_some_and(crate::pane::InputState::mouse_reporting_enabled) + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) } pub(crate) fn should_capture_host_mouse_from( diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index af743eaa..bf39199e 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -2574,7 +2574,8 @@ pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS: GhosttyTermi pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SELECTION: GhosttyTerminalData = 31; #[doc = " Whether the viewport is currently pinned to the active area.\n\n This is true when the viewport is following the active terminal area,\n and false when the user has scrolled into history.\n\n Output type: bool *"] pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE: GhosttyTerminalData = 32; -#[doc = " Whether the viewport is currently pinned to the active area.\n\n This is true when the viewport is following the active terminal area,\n and false when the user has scrolled into history.\n\n Output type: bool *"] +#[doc = " Whether xterm modifyOtherKeys mode 2 is enabled.\n\n Output type: bool *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MODIFY_OTHER_KEYS: GhosttyTerminalData = 33; pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MAX_VALUE: GhosttyTerminalData = 2147483647; #[doc = " Terminal data types.\n\n These values specify what type of data to extract from a terminal\n using `ghostty_terminal_get`.\n\n @ingroup terminal"] pub type GhosttyTerminalData = ::std::os::raw::c_uint; diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index cea7c283..c044d3b0 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1034,6 +1034,10 @@ impl Terminal { self.get_bool(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING) } + pub fn modify_other_keys_enabled(&self) -> Result { + self.get_bool(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MODIFY_OTHER_KEYS) + } + pub fn active_screen(&self) -> Result { let mut out = ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_PRIMARY; unsafe { @@ -3635,6 +3639,17 @@ mod tests { assert_eq!(encoded_mouse, b"\x1b[<0;1;1M"); } + #[test] + fn modify_other_keys_query_tracks_mode_two() { + let mut terminal = Terminal::new(80, 24, 0).unwrap(); + + assert!(!terminal.modify_other_keys_enabled().unwrap()); + terminal.write(b"\x1b[>4;2m"); + assert!(terminal.modify_other_keys_enabled().unwrap()); + terminal.write(b"\x1b[>4;0m"); + assert!(!terminal.modify_other_keys_enabled().unwrap()); + } + #[test] fn terminal_read_text_viewport_unwraps_soft_wrapped_selection() { let mut terminal = Terminal::new(5, 3, 0).unwrap(); diff --git a/src/input/mod.rs b/src/input/mod.rs index 21893303..8108bf99 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -9,8 +9,10 @@ pub use encode::{ }; #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; +#[cfg(any(unix, test))] +pub use model::MouseProtocolMode; pub use model::{ - host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, - MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord, + host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, TerminalKey, + TextCommit, WindowsKeyRecord, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index 21252d2d..cada5506 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -295,6 +295,7 @@ impl KeyboardProtocol { } } +#[cfg(any(unix, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MouseProtocolMode { @@ -305,7 +306,9 @@ pub enum MouseProtocolMode { AnyMotion, } +#[cfg(any(unix, test))] impl MouseProtocolMode { + #[cfg(test)] pub fn reporting_enabled(self) -> bool { self != Self::None } diff --git a/src/pane.rs b/src/pane.rs index 0b3edf7d..b2e1603d 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -40,6 +40,8 @@ use self::agent_detection::{ DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, }; +#[cfg(any(unix, test))] +pub use self::terminal::InputState; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{ TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalTextMatch, @@ -47,7 +49,7 @@ pub(crate) use self::terminal::{ }; pub use self::{ state::PaneState, - terminal::{InputState, ScrollMetrics, TerminalCursorState}, + terminal::{ScrollMetrics, TerminalCursorState}, }; const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1); @@ -1671,11 +1673,7 @@ impl PaneRuntime { #[cfg(unix)] pub fn handoff_history_ansi(&self) -> Option { - if self - .terminal - .input_state() - .is_some_and(|input_state| input_state.alternate_screen) - { + if self.terminal.alternate_screen_active() { return None; } self.snapshot_history().map(|history| { @@ -2688,12 +2686,37 @@ impl PaneRuntime { self.terminal.word_motion_target(row, col, motion) } + #[cfg(any(unix, test))] pub fn input_state(&self) -> Option { #[cfg(test)] AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1); self.terminal.input_state() } + pub fn keyboard_report_all_requested(&self) -> bool { + self.terminal.keyboard_report_all_requested() + } + + pub fn bracketed_paste_enabled(&self) -> bool { + self.terminal.bracketed_paste_enabled() + } + + pub fn focus_reporting_enabled(&self) -> bool { + self.terminal.focus_reporting_enabled() + } + + pub fn mouse_reporting_enabled(&self) -> bool { + self.terminal.mouse_reporting_enabled() + } + + pub fn sgr_pixel_mouse_enabled(&self) -> bool { + self.terminal.sgr_pixel_mouse_enabled() + } + + pub fn plain_page_keys_use_host_scrollback(&self) -> Option { + self.terminal.plain_page_keys_use_host_scrollback() + } + pub fn alternate_screen_active(&self) -> bool { self.terminal.alternate_screen_active() } @@ -2831,10 +2854,7 @@ impl PaneRuntime { } fn paste_payload(&self, text: String) -> Bytes { - let bracketed = self - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false); + let bracketed = self.bracketed_paste_enabled(); let payload = if bracketed { format!("\x1b[200~{text}\x1b[201~") } else { @@ -2844,11 +2864,7 @@ impl PaneRuntime { } pub fn try_send_focus_event(&self, event: crate::ghostty::FocusEvent) -> bool { - if !self - .input_state() - .map(|state| state.focus_reporting) - .unwrap_or(false) - { + if !self.focus_reporting_enabled() { return false; } @@ -2881,7 +2897,7 @@ impl PaneRuntime { position: crate::input::mouse::Position, modifiers: crossterm::event::KeyModifiers, ) -> Option> { - if !self.input_state()?.mouse_protocol_mode.reporting_enabled() { + if !self.mouse_reporting_enabled() { return None; } self.terminal.encode_mouse_button(kind, position, modifiers) @@ -2919,7 +2935,6 @@ impl PaneRuntime { &self, kind: crossterm::event::MouseEventKind, ) -> Option> { - self.input_state()?; if self.wheel_routing()? != WheelRouting::AlternateScroll { return None; } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index b853f0b0..0b06302e 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use bytes::Bytes; use ratatui::style::{Color, Modifier, Style}; use ratatui::{layout::Rect, Frame}; +#[cfg(any(unix, test))] use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tracing::{debug, error}; @@ -113,6 +114,7 @@ fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: boo } } +#[cfg(any(unix, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct InputState { pub alternate_screen: bool, @@ -128,6 +130,7 @@ pub struct InputState { pub color_scheme_reporting: bool, } +#[cfg(test)] impl InputState { pub fn mouse_reporting_enabled(self) -> bool { self.mouse_protocol_mode.reporting_enabled() @@ -385,10 +388,35 @@ impl PaneTerminal { Some((RetainedTextBuffer::new_search(cols, rows, 0), active_screen)) } + #[cfg(any(unix, test))] pub fn input_state(&self) -> Option { self.ghostty.input_state() } + pub fn keyboard_report_all_requested(&self) -> bool { + self.ghostty.keyboard_report_all_requested() + } + + pub fn bracketed_paste_enabled(&self) -> bool { + self.ghostty.bracketed_paste_enabled() + } + + pub fn focus_reporting_enabled(&self) -> bool { + self.ghostty.focus_reporting_enabled() + } + + pub fn mouse_reporting_enabled(&self) -> bool { + self.ghostty.mouse_reporting_enabled() + } + + pub fn sgr_pixel_mouse_enabled(&self) -> bool { + self.ghostty.sgr_pixel_mouse_enabled() + } + + pub fn plain_page_keys_use_host_scrollback(&self) -> Option { + self.ghostty.plain_page_keys_use_host_scrollback() + } + pub fn alternate_screen_active(&self) -> bool { self.ghostty.alternate_screen_active() } @@ -1668,14 +1696,66 @@ impl GhosttyPaneTerminal { core.kitty_keyboard.replay_ansi() } + pub fn keyboard_report_all_requested(&self) -> bool { + self.core.lock().is_ok_and(|core| { + let protocol = crate::input::KeyboardProtocol::from_kitty_flags( + core.terminal.kitty_keyboard_flags().unwrap_or(0) as u16, + ); + protocol.reports_all_keys() + || (protocol.reports_event_types() + && core.terminal.modify_other_keys_enabled().unwrap_or(false)) + }) + } + + pub fn bracketed_paste_enabled(&self) -> bool { + self.mode_enabled(crate::ghostty::MODE_BRACKETED_PASTE) + } + + pub fn focus_reporting_enabled(&self) -> bool { + self.mode_enabled(crate::ghostty::MODE_FOCUS_EVENT) + } + + pub fn mouse_reporting_enabled(&self) -> bool { + self.core + .lock() + .is_ok_and(|core| core.terminal.mouse_tracking_enabled().unwrap_or(false)) + } + + pub fn sgr_pixel_mouse_enabled(&self) -> bool { + self.mode_enabled(crate::ghostty::MODE_MOUSE_SGR_PIXELS) + } + + fn mode_enabled(&self, mode: u16) -> bool { + self.core + .lock() + .is_ok_and(|core| core.terminal.mode_get(mode).unwrap_or(false)) + } + + pub fn plain_page_keys_use_host_scrollback(&self) -> Option { + let core = self.core.lock().ok()?; + let alternate_screen = + core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate; + let mouse_reporting = core.terminal.mouse_tracking_enabled().ok()?; + let application_cursor = core + .terminal + .mode_get(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS) + .ok()?; + let bracketed_paste = core + .terminal + .mode_get(crate::ghostty::MODE_BRACKETED_PASTE) + .ok()?; + Some(!alternate_screen && !mouse_reporting && (!application_cursor || bracketed_paste)) + } + pub fn alternate_screen_active(&self) -> bool { self.core.lock().is_ok_and(|core| { core.terminal.active_screen().ok() == Some(crate::ghostty::ActiveScreen::Alternate) }) } - // This aggregate snapshot performs multiple terminal queries and may format - // keyboard state. Pane-scaled callers should add a narrow accessor instead. + // This aggregate snapshot performs multiple terminal queries. Pane-scaled + // callers should add a narrow accessor instead. + #[cfg(any(unix, test))] pub fn input_state(&self) -> Option { let Ok(core) = self.core.lock() else { return None; @@ -1738,14 +1818,7 @@ impl GhosttyPaneTerminal { mouse_protocol_mode, mouse_protocol_encoding, mouse_alternate_scroll, - #[cfg(windows)] - modify_other_keys: core.kitty_keyboard.modify_other_keys_enabled(), - #[cfg(not(windows))] - modify_other_keys: core - .terminal - .keyboard_state_ansi() - .ok() - .is_some_and(|ansi| !ansi.is_empty()), + modify_other_keys: core.terminal.modify_other_keys_enabled().ok()?, color_scheme_reporting: core .terminal .mode_get(crate::ghostty::MODE_COLOR_SCHEME_REPORT) diff --git a/src/render_signal.rs b/src/render_signal.rs index f4b4391d..a6ebd839 100644 --- a/src/render_signal.rs +++ b/src/render_signal.rs @@ -16,7 +16,13 @@ pub(crate) struct RenderRequest { #[derive(Debug, Default)] pub(crate) struct RenderSignal { pending: AtomicBool, - request: Mutex, + state: Mutex, +} + +#[derive(Debug, Default)] +struct RenderSignalState { + request: RenderRequest, + immediate_pty_sources: HashSet, } impl RenderSignal { @@ -29,78 +35,75 @@ impl RenderSignal { } pub(crate) fn request_generic(&self) { - let mut request = self - .request + let mut state = self + .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - request.generic = true; + state.request.generic = true; self.pending.store(true, Ordering::Release); } - /// Returns true when the signal becomes pending or a new PTY source joins it. - /// - /// A new source may be visible even when the existing pending sources are - /// hidden, so the consumer must re-evaluate the coalesced request. + /// Returns true when the signal becomes pending or visible PTY work joins it. pub(crate) fn request_pty(&self, pane_id: PaneId) -> bool { - let mut request = self - .request + let mut state = self + .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let source_added = request.pty_sources.insert(pane_id); + let source_added = state.request.pty_sources.insert(pane_id); + let wake_for_source = source_added && state.immediate_pty_sources.contains(&pane_id); let became_pending = !self.pending.swap(true, Ordering::AcqRel); - became_pending || source_added + became_pending || wake_for_source } - pub(crate) fn has_generic_or_terminal_title(&self) -> bool { - let request = self - .request + pub(crate) fn set_immediate_pty_sources(&self, sources: HashSet) { + self.state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - request.generic || !request.terminal_title_sources.is_empty() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .immediate_pty_sources = sources; } - /// Checks pending PTY origins without allocating a source snapshot. - /// Keep the predicate narrow because producers share this lock. - pub(crate) fn has_pty_source_matching( - &self, - mut predicate: impl FnMut(PaneId) -> bool, - ) -> bool { - self.request + pub(crate) fn has_immediate_work(&self) -> bool { + let state = self + .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pty_sources - .iter() - .copied() - .any(&mut predicate) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.request.generic + || !state.request.terminal_title_sources.is_empty() + || state + .request + .pty_sources + .iter() + .any(|pane_id| state.immediate_pty_sources.contains(pane_id)) } /// Coalesces terminal-title changes separately from ordinary PTY damage so /// consumers can update metadata without inspecting every pane. pub(crate) fn request_terminal_title(&self, pane_id: PaneId) -> bool { - let mut request = self - .request + let mut state = self + .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let source_added = request.terminal_title_sources.insert(pane_id); + let source_added = state.request.terminal_title_sources.insert(pane_id); let became_pending = !self.pending.swap(true, Ordering::AcqRel); became_pending || source_added } pub(crate) fn pending_terminal_title_sources(&self) -> HashSet { - self.request + self.state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) + .request .terminal_title_sources .clone() } pub(crate) fn take(&self) -> RenderRequest { - let mut request = self - .request + let mut state = self + .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); self.pending.store(false, Ordering::Release); - std::mem::take(&mut *request) + std::mem::take(&mut state.request) } } @@ -116,7 +119,7 @@ mod tests { assert!(signal.request_pty(first)); assert!(!signal.request_pty(first)); - assert!(signal.request_pty(second)); + assert!(!signal.request_pty(second)); let request = signal.take(); assert!(!request.generic); @@ -125,6 +128,31 @@ mod tests { assert!(!signal.is_pending()); } + #[test] + fn hidden_pty_sources_coalesce_to_one_wake() { + let signal = RenderSignal::new(); + signal.set_immediate_pty_sources(HashSet::from([PaneId::from_raw(100)])); + + let wakes = (1..=50) + .filter(|pane_id| signal.request_pty(PaneId::from_raw(*pane_id))) + .count(); + + assert_eq!(wakes, 1); + } + + #[test] + fn immediate_pty_source_wakes_pending_hidden_work() { + let signal = RenderSignal::new(); + let hidden = PaneId::from_raw(10); + let visible = PaneId::from_raw(20); + signal.set_immediate_pty_sources(HashSet::from([visible])); + + assert!(signal.request_pty(hidden)); + assert!(!signal.request_pty(PaneId::from_raw(30))); + assert!(signal.request_pty(visible)); + assert!(!signal.request_pty(visible)); + } + #[test] fn terminal_title_source_wakes_pending_pty_work() { let signal = RenderSignal::new(); @@ -158,7 +186,7 @@ mod tests { let pane_id = PaneId::from_raw(10); signal.request_generic(); - assert!(signal.request_pty(pane_id)); + assert!(!signal.request_pty(pane_id)); let request = signal.take(); assert!(request.generic); diff --git a/src/server/headless.rs b/src/server/headless.rs index ae0d7b20..9161cd34 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -357,8 +357,8 @@ fn apply_terminal_attach_scroll( }; if let AttachScrollSource::PageKey { input } = source { let host_scroll = runtime - .input_state() - .is_some_and(crate::pane::InputState::plain_page_keys_use_host_scrollback); + .plain_page_keys_use_host_scrollback() + .unwrap_or(false); if host_scroll { match direction { AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), @@ -695,6 +695,7 @@ impl HeadlessServer { } self.drain_client_config_reload_request(); + self.sync_immediate_pty_sources(); self.stream_host_mouse_capture_mode(); self.stream_host_keyboard_enhancement_flags(); @@ -3998,10 +3999,7 @@ impl HeadlessServer { ) }) }) - .and_then(crate::terminal::TerminalRuntime::input_state) - .is_some_and(|state| { - state.mouse_protocol_encoding == crate::input::MouseProtocolEncoding::SgrPixels - }); + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { if !client.is_full_app_client() { @@ -4086,24 +4084,33 @@ impl HeadlessServer { needs_full_render: bool, needs_graphics_render: bool, ) -> bool { - if needs_full_render - || needs_graphics_render - || self.app.render_dirty.has_generic_or_terminal_title() - { - return true; - } + needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() + } + fn sync_immediate_pty_sources(&self) { let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - if !has_app_target && direct_terminal_targets.is_empty() { - return false; + let mut pane_ids = if has_app_target { + self.app.state.app_surface_pane_ids() + } else { + HashSet::new() + }; + if !direct_terminal_targets.is_empty() { + for workspace in &self.app.state.workspaces { + for tab in &workspace.tabs { + pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { + direct_terminal_targets + .contains(pane.attached_terminal_id.as_str()) + .then_some(pane_id) + })); + } + } + if let Some(popup) = &self.app.state.popup_pane { + if direct_terminal_targets.contains(popup.terminal_id.as_str()) { + pane_ids.insert(popup.pane_id); + } + } } - self.app.render_dirty.has_pty_source_matching(|pane_id| { - self.pty_source_visible_to_render_targets( - pane_id, - has_app_target, - &direct_terminal_targets, - ) - }) + self.app.render_dirty.set_immediate_pty_sources(pane_ids); } fn pty_render_targets(&self) -> (bool, HashSet<&str>) { @@ -9684,6 +9691,7 @@ next_tab = "" fn visible_source_wakes_pending_hidden_work() { let (server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); let visible_pane = server.app.state.workspaces[0].tabs[0].root_pane; + server.sync_immediate_pty_sources(); assert!(server.app.render_dirty.request_pty(background_pane)); assert!(!server.has_pending_presentation_work(false, false)); @@ -9776,15 +9784,17 @@ next_tab = "" } #[test] - fn direct_terminal_observer_keeps_hidden_pty_source_renderable() { - let (mut server, background_pane) = hidden_pty_visibility_test_server(&[]); + fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_app_client() { + let (mut server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); + assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + let terminal_id = server.app.state.workspaces[0] .terminal_id(background_pane) .expect("background terminal id") .to_string(); let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( - 1, + 2, ClientConnection::new_with_mode( ClientConnectionMode::TerminalObserve { terminal_id }, None, @@ -9792,7 +9802,7 @@ next_tab = "" crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, - 1, + 2, RenderEncoding::SemanticFrame, false, Some(client_tx), @@ -9800,6 +9810,12 @@ next_tab = "" ); assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + + let hidden_pane = server.app.state.workspaces[0].tabs[0].root_pane; + server.sync_immediate_pty_sources(); + assert!(server.app.render_dirty.request_pty(background_pane)); + assert!(server.has_pending_presentation_work(false, false)); + assert!(server.app.render_dirty.request_pty(hidden_pane)); } #[tokio::test] diff --git a/src/server/terminal_attach.rs b/src/server/terminal_attach.rs index 01fc899f..e7a52be5 100644 --- a/src/server/terminal_attach.rs +++ b/src/server/terminal_attach.rs @@ -2,11 +2,7 @@ pub(crate) fn paste_payload_for_runtime( runtime: &crate::terminal::TerminalRuntime, text: &str, ) -> String { - if runtime - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false) - { + if runtime.bracketed_paste_enabled() { format!("\x1b[200~{text}\x1b[201~") } else { text.to_owned() diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 619c98a4..5051a46b 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -313,13 +313,33 @@ impl TerminalRuntime { /// Collects the complete terminal input-mode snapshot. /// - /// This performs multiple terminal queries and may format keyboard state. - /// Keep it out of render/layout and pane-scaled loops; add a narrow accessor - /// when only one terminal fact is needed. + /// This performs multiple terminal queries. Keep it out of render/layout + /// and pane-scaled loops; add a narrow accessor when one fact is needed. + #[cfg(test)] pub fn input_state(&self) -> Option { self.0.input_state() } + pub fn keyboard_report_all_requested(&self) -> bool { + self.0.keyboard_report_all_requested() + } + + pub fn bracketed_paste_enabled(&self) -> bool { + self.0.bracketed_paste_enabled() + } + + pub fn mouse_reporting_enabled(&self) -> bool { + self.0.mouse_reporting_enabled() + } + + pub fn sgr_pixel_mouse_enabled(&self) -> bool { + self.0.sgr_pixel_mouse_enabled() + } + + pub fn plain_page_keys_use_host_scrollback(&self) -> Option { + self.0.plain_page_keys_use_host_scrollback() + } + /// Reads only whether the alternate screen is active. pub fn alternate_screen_active(&self) -> bool { self.0.alternate_screen_active() -- cgit v1.2.3