aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-08-09 20:36:11 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-08-09 20:36:11 +0300
commite2aa86a9e8b6445488ef11b2c0a8b4c22a6f79ce (patch)
treeea3ea433fca073ae8e0636f42a4c4bf03ea87777 /src
parent4e31084495120674a0cf403a27d3d91c5efb34fe (diff)
test: guard pane-scaled render performance
refs #2550
Diffstat (limited to 'src')
-rw-r--r--src/pane.rs17
-rw-r--r--src/pane/terminal.rs2
-rw-r--r--src/server/render_stream.rs170
-rw-r--r--src/terminal/runtime.rs6
-rw-r--r--src/ui/panes.rs2
5 files changed, 197 insertions, 0 deletions
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<usize> = 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<InputState> {
+ #[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<InputState> {
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<Workspace>) -> 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<crate::pane::InputState> {
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<Str
Some(format!(" {} ", truncate_end(label, max_label_width)))
}
+// Full view computation reaches this helper for active and background panes.
+// Keep terminal queries narrow, allocation-free, and short under the core lock.
fn terminal_inner_rect(rt: &TerminalRuntime, pane_inner: Rect, pane_scrollbars: bool) -> Rect {
if !pane_scrollbars || pane_inner.width <= 4 || rt.alternate_screen_active() {
return pane_inner;