From e2aa86a9e8b6445488ef11b2c0a8b4c22a6f79ce Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 9 Aug 2026 20:36:11 +0300 Subject: test: guard pane-scaled render performance refs #2550 --- src/pane.rs | 17 +++++ src/pane/terminal.rs | 2 + src/server/render_stream.rs | 170 ++++++++++++++++++++++++++++++++++++++++++++ src/terminal/runtime.rs | 6 ++ src/ui/panes.rs | 2 + 5 files changed, 197 insertions(+) (limited to 'src') diff --git a/src/pane.rs b/src/pane.rs index b2c01f01..130c75e5 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -54,6 +54,21 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration:: const PANE_TERM: &str = "xterm-256color"; const PANE_COLORTERM: &str = "truecolor"; +#[cfg(test)] +thread_local! { + static AGGREGATE_INPUT_STATE_READS: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_aggregate_input_state_reads() { + AGGREGATE_INPUT_STATE_READS.set(0); +} + +#[cfg(test)] +pub(crate) fn aggregate_input_state_reads() -> usize { + AGGREGATE_INPUT_STATE_READS.get() +} + fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { // Each pane is rendered by herdr's own terminal layer, not the outer terminal // that launched the app. Advertising the inherited TERM leaks the host terminal @@ -2593,6 +2608,8 @@ impl PaneRuntime { } pub fn input_state(&self) -> Option { + #[cfg(test)] + AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1); self.terminal.input_state() } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 1c10726c..9575ad6f 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1675,6 +1675,8 @@ impl GhosttyPaneTerminal { }) } + // This aggregate snapshot performs multiple terminal queries and may format + // keyboard state. Pane-scaled callers should add a narrow accessor instead. pub fn input_state(&self) -> Option { let Ok(core) = self.core.lock() else { return None; diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index f1f6c428..f14deb5e 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -467,3 +467,173 @@ fn focused_terminal_suppresses_host_cursor( .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) .is_some_and(crate::terminal::TerminalRuntime::synchronized_output_active) } + +#[cfg(test)] +mod render_scale_benchmark { + use std::hint::black_box; + use std::time::Instant; + + use ratatui::layout::Direction; + + use super::*; + use crate::app::Mode; + use crate::terminal::TerminalRuntime; + use crate::workspace::Workspace; + + const AREA: Rect = Rect::new(0, 0, 120, 40); + const SAMPLE_COUNT: usize = 40; + const WARMUP_COUNT: usize = 5; + + #[derive(Clone, Copy)] + struct RenderStats { + median_us: u128, + p95_us: u128, + max_us: u128, + } + + fn history() -> String { + (0..2_000).map(|line| format!("line-{line}\r\n")).collect() + } + + fn runtime(history: &str) -> TerminalRuntime { + TerminalRuntime::test_with_scrollback_bytes( + AREA.width, + AREA.height, + 1024 * 1024, + history.as_bytes(), + ) + } + + fn app_with_workspaces(workspace_count: usize) -> AppState { + let history = history(); + let workspaces = (0..workspace_count) + .map(|index| { + let mut workspace = Workspace::test_new(&format!("bench-{}", index + 1)); + let root_pane = workspace.tabs[0].root_pane; + workspace.tabs[0] + .runtimes + .insert(root_pane, runtime(&history)); + workspace + }) + .collect(); + app_with(workspaces) + } + + fn app_with_active_panes(pane_count: usize) -> AppState { + let history = history(); + let mut workspace = Workspace::test_new("bench"); + let root_pane = workspace.tabs[0].root_pane; + workspace.tabs[0] + .runtimes + .insert(root_pane, runtime(&history)); + let mut pane_ids = vec![root_pane]; + + for index in 1..pane_count { + let target = pane_ids[(index - 1) / 2]; + workspace.tabs[0].layout.focus_pane(target); + let direction = if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }; + let pane_id = workspace.test_split(direction); + workspace.tabs[0] + .runtimes + .insert(pane_id, runtime(&history)); + pane_ids.push(pane_id); + } + + app_with(vec![workspace]) + } + + fn app_with(workspaces: Vec) -> AppState { + let mut app = AppState::test_new(); + app.mode = Mode::Terminal; + app.pane_scrollbars = true; + app.workspaces = workspaces; + app.active = Some(0); + app.selected = 0; + app + } + + fn profile(mut app: AppState) -> RenderStats { + for _ in 0..WARMUP_COUNT { + black_box(render_virtual(&mut app, AREA, true)); + } + + let mut samples = Vec::with_capacity(SAMPLE_COUNT); + for _ in 0..SAMPLE_COUNT { + let started = Instant::now(); + black_box(render_virtual(&mut app, AREA, true)); + samples.push(started.elapsed().as_micros()); + } + samples.sort_unstable(); + + RenderStats { + median_us: samples[SAMPLE_COUNT / 2], + p95_us: samples[(SAMPLE_COUNT - 1) * 95 / 100], + max_us: samples[SAMPLE_COUNT - 1], + } + } + + fn profile_cardinalities(build: fn(usize) -> AppState) -> [(usize, RenderStats); 3] { + [1, 15, 50].map(|count| (count, profile(build(count)))) + } + + fn print_profiles(label: &str, profiles: [(usize, RenderStats); 3]) { + let baseline_median_us = profiles[0].1.median_us as f64; + let baseline_p95_us = profiles[0].1.p95_us as f64; + println!("{label}"); + println!(" count median_us p95_us max_us median_vs_1x p95_vs_1x"); + for (count, stats) in profiles { + println!( + "{count:>10} {:>9} {:>6} {:>6} {:>12.2} {:>9.2}", + stats.median_us, + stats.p95_us, + stats.max_us, + stats.median_us as f64 / baseline_median_us, + stats.p95_us as f64 / baseline_p95_us, + ); + } + } + + fn assert_full_render_avoids_aggregate_input_state(mut app: AppState, scenario: &str) { + crate::pane::reset_aggregate_input_state_reads(); + black_box(render_virtual(&mut app, AREA, true)); + assert_eq!( + crate::pane::aggregate_input_state_reads(), + 0, + "full render collected aggregate input state for {scenario}", + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn aggregate_input_state_counter_records_reads() { + let runtime = TerminalRuntime::test_with_screen_bytes(80, 24, b""); + crate::pane::reset_aggregate_input_state_reads(); + black_box(runtime.input_state()); + assert_eq!(crate::pane::aggregate_input_state_reads(), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn full_render_avoids_aggregate_input_state_reads() { + assert_full_render_avoids_aggregate_input_state( + app_with_workspaces(15), + "background workspaces", + ); + assert_full_render_avoids_aggregate_input_state(app_with_active_panes(15), "active panes"); + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "manual full-render scaling profile"] + async fn render_scale_profile() { + print_profiles( + "background-workspace resize/layout (one pane each)", + profile_cardinalities(app_with_workspaces), + ); + print_profiles( + "active panes (one workspace)", + profile_cardinalities(app_with_active_panes), + ); + } +} diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index d3900b68..89544a74 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -311,10 +311,16 @@ impl TerminalRuntime { self.0.word_motion_target(row, col, motion) } + /// 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. pub fn input_state(&self) -> Option { self.0.input_state() } + /// Reads only whether the alternate screen is active. pub fn alternate_screen_active(&self) -> bool { self.0.alternate_screen_active() } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index cce8ab02..f20b55b5 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -31,6 +31,8 @@ fn pane_border_title(label: &str, pane_width: u16, _focused: bool) -> Option Rect { if !pane_scrollbars || pane_inner.width <= 4 || rt.alternate_screen_active() { return pane_inner; -- cgit v1.2.3