diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-28 23:29:11 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-28 23:29:11 +0300 |
| commit | e27a4ea427c9ed6248c0ee80570e94ed7b1928bd (patch) | |
| tree | 0cae21a2b3939293be129aaaecb23641f68bab54 /src | |
| parent | e16d7d8c07a20f5ee0b4111808680bbcfd7df9ac (diff) | |
perf: skip rendering hidden pty updates
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 2 | ||||
| -rw-r--r-- | src/app/api.rs | 11 | ||||
| -rw-r--r-- | src/app/mod.rs | 17 | ||||
| -rw-r--r-- | src/app/popup.rs | 3 | ||||
| -rw-r--r-- | src/app/theme_sync.rs | 6 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 9 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/pane.rs | 23 | ||||
| -rw-r--r-- | src/persist/restore.rs | 28 | ||||
| -rw-r--r-- | src/render_signal.rs | 91 | ||||
| -rw-r--r-- | src/server/headless.rs | 352 | ||||
| -rw-r--r-- | src/server/notifications.rs | 2 | ||||
| -rw-r--r-- | src/terminal/id.rs | 4 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 14 | ||||
| -rw-r--r-- | src/ui/dialogs.rs | 2 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 2 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 2 | ||||
| -rw-r--r-- | src/workspace.rs | 23 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 12 |
19 files changed, 509 insertions, 95 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index c92a91e5..9ef4af68 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3708,7 +3708,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); diff --git a/src/app/api.rs b/src/app/api.rs index c46be676..76087e2d 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1,4 +1,3 @@ -use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; mod agent_view; @@ -91,7 +90,7 @@ impl App { .state .apply_workspace_git_statuses(&self.terminal_runtimes, results); if changed { - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } changed @@ -194,7 +193,7 @@ impl App { && self.respawn_shell_for_launch_pane(*pane_id) { self.overlay_panes.remove(pane_id); - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); return; } @@ -293,7 +292,7 @@ impl App { self.sync_full_lifecycle_authority_detection_pauses(); if terminal_cwd_reported { self.request_git_identity_refresh(Instant::now()); - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } for update in &pane_updates { @@ -1717,7 +1716,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); @@ -1809,7 +1808,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); diff --git a/src/app/mod.rs b/src/app/mod.rs index c8c8113a..1733e234 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -28,7 +28,6 @@ mod worktrees; use std::collections::{HashMap, HashSet}; use std::future::pending; use std::io::{self, Write}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -140,7 +139,7 @@ pub struct App { HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>, pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>, pub render_notify: Arc<Notify>, - pub render_dirty: Arc<AtomicBool>, + pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>, pub(crate) full_redraw_pending: bool, pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, pub(crate) local_terminal_notifications: bool, @@ -381,7 +380,7 @@ impl App { crate::kitty_graphics::set_enabled(config.experimental.kitty_graphics); let (event_tx, event_rx) = mpsc::channel::<AppEvent>(APP_EVENT_CHANNEL_CAPACITY); let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(AtomicBool::new(false)); + let render_dirty = Arc::new(crate::render_signal::RenderSignal::new()); // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); @@ -919,7 +918,7 @@ impl App { while !self.state.should_quit { self.reap_finished_custom_commands(); - if self.render_dirty.load(Ordering::Acquire) { + if self.render_dirty.is_pending() { needs_render = true; } let terminal_title_changed = self.sync_terminal_titles(); @@ -1044,7 +1043,7 @@ impl App { self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; if needs_render && self.can_render_now(now) { - self.render_dirty.swap(false, Ordering::AcqRel); + let _ = self.render_dirty.take(); let _sync_output = SyncOutputGuard::begin()?; let kitty_graphics_enabled = self.state.kitty_graphics_enabled; if self.full_redraw_pending { @@ -1094,7 +1093,7 @@ impl App { } self.sync_pending_agent_resume_deadline(now); if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } self.last_render_at = Some(now); @@ -1144,7 +1143,7 @@ impl App { self.input_rx = None; } LoopEvent::RenderRequested => { - if self.render_dirty.load(Ordering::Acquire) { + if self.render_dirty.is_pending() { needs_render = true; } } @@ -2165,7 +2164,7 @@ mod tests { fn git_status_event_marks_render_dirty_when_status_changes() { let mut app = test_app(); app.state.workspaces.push(Workspace::test_new("one")); - app.render_dirty.store(false, Ordering::Release); + let _ = app.render_dirty.take(); let workspace_id = app.state.workspaces[0].id.clone(); let resolved_identity_cwd = app.state.workspaces[0].resolved_identity_cwd().unwrap(); @@ -2183,7 +2182,7 @@ mod tests { cache_updates: Vec::new(), }); - assert!(app.render_dirty.load(Ordering::Acquire)); + assert!(app.render_dirty.is_pending()); } #[test] diff --git a/src/app/popup.rs b/src/app/popup.rs index 3aeef35c..3356ab14 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -34,8 +34,7 @@ impl App { } else { Mode::Navigate }; - self.render_dirty - .store(true, std::sync::atomic::Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); true } diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index c41ca24c..e4284f69 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -1,5 +1,3 @@ -use std::sync::atomic::Ordering; - use super::App; impl App { @@ -92,7 +90,7 @@ impl App { } self.state.theme_name = theme_name; self.state.palette = palette; - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); true } @@ -102,7 +100,7 @@ impl App { runtime.apply_host_terminal_theme(self.state.host_terminal_theme); } - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index c839c04a..23e17b4d 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -1,4 +1,3 @@ -use std::sync::atomic::Ordering; use std::time::{SystemTime, UNIX_EPOCH}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -864,14 +863,14 @@ impl App { } } } - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } Err(message) => { tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed"); create.creating = false; create.error = Some(message); - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } } @@ -942,7 +941,7 @@ impl App { } else { Mode::Navigate }; - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } Err(message) => { @@ -956,7 +955,7 @@ impl App { } else { remove.error = Some(message); } - self.render_dirty.store(true, Ordering::Release); + self.render_dirty.request_generic(); self.render_notify.notify_one(); } } diff --git a/src/main.rs b/src/main.rs index 65dc7c11..b4517e21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,6 +88,7 @@ mod raw_input; mod release_notes; mod remote; mod render_prof; +mod render_signal; mod selection; mod server; mod session; diff --git a/src/pane.rs b/src/pane.rs index a26dad6b..3bf94ec0 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -22,6 +22,7 @@ use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::PaneId; use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult}; +use crate::render_signal::RenderSignal; mod agent_detection; mod cursor; @@ -1603,7 +1604,7 @@ impl PaneRuntime { launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { Self::spawn_with_initial_history( pane_id, @@ -1635,7 +1636,7 @@ impl PaneRuntime { initial_history_ansi: Option<&str>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { let windows_powershell_prompt_cwd_reporting = uses_windows_powershell_pane_shell(shell_config); @@ -1677,7 +1678,7 @@ impl PaneRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { let mut cmd = crate::platform::pane_custom_command_pty_builder(command); cmd.cwd(cwd); @@ -1713,7 +1714,7 @@ impl PaneRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { let Some((program, args)) = argv.split_first() else { return Err(std::io::Error::new( @@ -1751,7 +1752,7 @@ impl PaneRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { let crate::handoff_runtime::ImportedHandoffRuntime { master_fd, state } = import; let crate::handoff_runtime::HandoffRuntimeState { @@ -1819,7 +1820,7 @@ impl PaneRuntime { let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); observe_detection_content_change(bytes, &detection_content_seq); - if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { + if result.request_render && render_dirty.request_pty(pane_id) { render_notify.notify_one(); } if let Some(delay) = result.render_delay { @@ -1827,7 +1828,7 @@ impl PaneRuntime { let render_dirty = render_dirty.clone(); delay_rt.spawn(async move { tokio::time::sleep(delay).await; - if !render_dirty.swap(true, Ordering::AcqRel) { + if render_dirty.request_pty(pane_id) { render_notify.notify_one(); } }); @@ -1900,7 +1901,7 @@ impl PaneRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, cmd: CommandBuilder, spawn_error_message: &'static str, initial_state: SpawnInitialState<'_>, @@ -1979,7 +1980,7 @@ impl PaneRuntime { if agent_detection == AgentDetection::Enabled { observe_detection_content_change(bytes, &detection_content_seq); } - if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { + if result.request_render && render_dirty.request_pty(pane_id) { render_notify.notify_one(); } if let Some(delay) = result.render_delay { @@ -1987,7 +1988,7 @@ impl PaneRuntime { let render_dirty = render_dirty.clone(); rt.spawn(async move { tokio::time::sleep(delay).await; - if !render_dirty.swap(true, Ordering::AcqRel) { + if render_dirty.request_pty(pane_id) { render_notify.notify_one(); } }); @@ -2253,7 +2254,7 @@ impl PaneRuntime { // Keep the terminal restore side effect separate from render notification state. #[allow(clippy::collapsible_if)] if pid > 0 && terminal.maybe_restore_host_terminal_theme(pane_id, pid) { - if !render_dirty.swap(true, Ordering::AcqRel) { + if render_dirty.request_pty(pane_id) { render_notify.notify_one(); } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index a1b07710..99b9a64c 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -1,6 +1,5 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::atomic::AtomicBool; use std::sync::Arc; use ratatui::layout::Direction; @@ -11,6 +10,7 @@ use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; use crate::pane::{PaneLaunchEnv, PaneState}; +use crate::render_signal::RenderSignal; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; @@ -40,7 +40,7 @@ struct RestoreRuntimeContext<'a> { resume_agents_on_restore: bool, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, } type RestoredSession = ( @@ -73,7 +73,7 @@ pub fn restore( resume_agents_on_restore: bool, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> RestoredSession { let mut imported_panes = HashMap::new(); restore_with_imports( @@ -100,7 +100,7 @@ pub fn restore_handoff( imports: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<RestoredSession> { restore_with_imports_strict( snapshot, @@ -196,7 +196,7 @@ fn restore_with_imports_strict( imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<RestoredSession> { let (restored, failed_imports) = restore_with_imports_and_failures( snapshot, @@ -236,7 +236,7 @@ fn restore_with_imports( imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> RestoredSession { restore_with_imports_and_failures( snapshot, @@ -265,7 +265,7 @@ fn restore_with_imports_and_failures( imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> RestoreFailures<RestoredSession> { let mut workspaces = Vec::new(); let mut terminals = HashMap::new(); @@ -1222,7 +1222,7 @@ mod tests { false, events, Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), + Arc::new(RenderSignal::new()), ); let terminal = terminals @@ -1315,7 +1315,7 @@ mod tests { false, events, Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), + Arc::new(RenderSignal::new()), ); let workspace = workspaces.first().expect("workspace should restore"); @@ -1422,7 +1422,7 @@ mod tests { false, events, Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), + Arc::new(RenderSignal::new()), ); let workspace = workspaces.first().expect("workspace should restore"); @@ -1533,7 +1533,7 @@ mod tests { true, events, Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), + Arc::new(RenderSignal::new()), ); let terminal = terminals @@ -1561,7 +1561,7 @@ mod tests { &mut imports, mpsc::channel(4).0, Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), + Arc::new(RenderSignal::new()), ) .expect("handoff restore should preserve pending native agent resume"); let handoff_terminal = handoff_terminals @@ -1583,7 +1583,7 @@ mod tests { let (snapshot, history) = snapshot_with_saved_pane_history(); let (events, _events_rx) = mpsc::channel(8); let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(AtomicBool::new(false)); + let render_dirty = Arc::new(RenderSignal::new()); let (_workspaces, _terminals, runtimes) = restore( &snapshot, @@ -1621,7 +1621,7 @@ mod tests { let (snapshot, _history) = snapshot_with_saved_pane_history(); let (events, _events_rx) = mpsc::channel(8); let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(AtomicBool::new(false)); + let render_dirty = Arc::new(RenderSignal::new()); let (_workspaces, _terminals, runtimes) = restore( &snapshot, diff --git a/src/render_signal.rs b/src/render_signal.rs new file mode 100644 index 00000000..d4544e5f --- /dev/null +++ b/src/render_signal.rs @@ -0,0 +1,91 @@ +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use crate::layout::PaneId; + +#[derive(Debug, Default)] +pub(crate) struct RenderRequest { + pub(crate) generic: bool, + pub(crate) pty_sources: HashSet<PaneId>, +} + +/// Coalesces render requests while retaining enough origin information for the +/// headless server to discard PTY-only updates hidden from every client. +#[derive(Debug, Default)] +pub(crate) struct RenderSignal { + pending: AtomicBool, + request: Mutex<RenderRequest>, +} + +impl RenderSignal { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn is_pending(&self) -> bool { + self.pending.load(Ordering::Acquire) + } + + pub(crate) fn request_generic(&self) { + let mut request = self + .request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + request.generic = true; + self.pending.store(true, Ordering::Release); + } + + /// Returns true when this request transitions the signal from idle to pending. + pub(crate) fn request_pty(&self, pane_id: PaneId) -> bool { + let mut request = self + .request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + request.pty_sources.insert(pane_id); + !self.pending.swap(true, Ordering::AcqRel) + } + + pub(crate) fn take(&self) -> RenderRequest { + let mut request = self + .request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.pending.store(false, Ordering::Release); + std::mem::take(&mut *request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coalesces_pty_sources_until_taken() { + let signal = RenderSignal::new(); + let first = PaneId::from_raw(10); + let second = PaneId::from_raw(20); + + assert!(signal.request_pty(first)); + assert!(!signal.request_pty(first)); + assert!(!signal.request_pty(second)); + + let request = signal.take(); + assert!(!request.generic); + assert_eq!(request.pty_sources, HashSet::from([first, second])); + assert!(!signal.is_pending()); + } + + #[test] + fn keeps_generic_and_pty_requests_distinct() { + let signal = RenderSignal::new(); + let pane_id = PaneId::from_raw(10); + + signal.request_generic(); + assert!(!signal.request_pty(pane_id)); + + let request = signal.take(); + assert!(request.generic); + assert_eq!(request.pty_sources, HashSet::from([pane_id])); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 7444f10d..899dbf9b 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -14,7 +14,7 @@ //! - Handles stale socket cleanup, explicit server stop, minimum terminal size, //! and pane spawn failure during restore -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -145,6 +145,42 @@ impl RenderImpact { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PtyRenderState { + Clean, + Hidden, + Visible, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RetainedRenderInput { + needs_full_render: bool, + needs_graphics_render: bool, + pty: PtyRenderState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RetainedRenderPlan { + Full, + Graphics, + Pty, + HiddenPty, +} + +fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { + if input.needs_full_render { + RetainedRenderPlan::Full + } else if input.needs_graphics_render && input.pty != PtyRenderState::Visible { + RetainedRenderPlan::Graphics + } else { + match input.pty { + PtyRenderState::Visible => RetainedRenderPlan::Pty, + PtyRenderState::Hidden => RetainedRenderPlan::HiddenPty, + PtyRenderState::Clean => RetainedRenderPlan::Full, + } + } +} + fn record_render_impact(source: &'static str, impact: RenderImpact) { let event = match (source, impact) { ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", @@ -511,10 +547,10 @@ impl HeadlessServer { continue; } - // 1. Check render_dirty flag from PTY reader tasks. - if self.app.render_dirty.load(Ordering::Acquire) { + // 1. Check the coalesced render signal from PTY readers and generic runtime work. + if self.app.render_dirty.is_pending() { needs_render = true; - crate::render_prof::event("render.request.pty_dirty"); + crate::render_prof::event("render.request.signal"); } let terminal_title_changed = self.app.sync_terminal_titles(); if terminal_title_changed && self.app.terminal_title_sidebar_configured() { @@ -618,28 +654,54 @@ impl HeadlessServer { // 7. Render virtually and stream frames. if needs_render && self.app.can_render_now(now) { crate::render_prof::event("render.attempt"); - let pty_dirty = self.app.render_dirty.swap(false, Ordering::AcqRel); + let render_request = self.app.render_dirty.take(); + let pty_dirty = !render_request.pty_sources.is_empty(); if pty_dirty { crate::render_prof::event("render.attempt.pty_dirty"); + crate::render_prof::counter( + "render.attempt.pty_sources", + render_request.pty_sources.len() as u64, + ); + } + if render_request.generic { + needs_full_render = true; + crate::render_prof::event("full_render_cause.generic_dirty"); } if needs_full_render { crate::render_prof::event("retained_gate.needs_full_render"); } else if !pty_dirty { crate::render_prof::event("retained_gate.not_pty_dirty"); } - let mut deferred_graphics = false; - let rendered_retained = if needs_graphics_render && !needs_full_render && !pty_dirty + let pty = if !pty_dirty { + PtyRenderState::Clean + } else if self.pty_sources_visible_to_any_render_target(&render_request.pty_sources) { - match self.render_retained_graphics_update_and_stream() { - RetainedGraphicsOutcome::Sent => true, - RetainedGraphicsOutcome::Deferred => { - deferred_graphics = true; - false + PtyRenderState::Visible + } else { + PtyRenderState::Hidden + }; + let mut deferred_graphics = false; + let rendered_retained = match retained_render_plan(RetainedRenderInput { + needs_full_render, + needs_graphics_render, + pty, + }) { + RetainedRenderPlan::Full => false, + RetainedRenderPlan::Graphics => { + match self.render_retained_graphics_update_and_stream() { + RetainedGraphicsOutcome::Sent => true, + RetainedGraphicsOutcome::Deferred => { + deferred_graphics = true; + false + } + RetainedGraphicsOutcome::Fallback => false, } - RetainedGraphicsOutcome::Fallback => false, } - } else { - pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream() + RetainedRenderPlan::Pty => self.render_retained_pty_update_and_stream(), + RetainedRenderPlan::HiddenPty => { + crate::render_prof::event("render.skipped.hidden_sources"); + true + } }; if deferred_graphics { needs_render = false; @@ -737,7 +799,7 @@ impl HeadlessServer { } } LoopEvent::RenderRequested => { - if self.app.render_dirty.load(Ordering::Acquire) { + if self.app.render_dirty.is_pending() { needs_render = true; } } @@ -3396,6 +3458,85 @@ impl HeadlessServer { } } + fn pty_sources_visible_to_any_render_target( + &self, + sources: &HashSet<crate::layout::PaneId>, + ) -> bool { + let mut has_app_target = false; + let mut direct_terminal_targets = HashSet::new(); + for client in self + .clients + .values() + .filter(|client| client.writer.is_some()) + { + match &client.mode { + ClientConnectionMode::App if client.is_full_app_client() => { + has_app_target = true; + } + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + direct_terminal_targets.insert(terminal_id.as_str()); + } + ClientConnectionMode::App => {} + } + } + if !has_app_target && direct_terminal_targets.is_empty() { + return false; + } + + sources.iter().any(|&pane_id| { + let terminal_id = self.terminal_id_for_pane(pane_id); + (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) + || terminal_id + .is_none_or(|source| direct_terminal_targets.contains(source.as_str())) + }) + } + + fn terminal_id_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalId> { + if let Some(popup) = self + .app + .state + .popup_pane + .as_ref() + .filter(|popup| popup.pane_id == pane_id) + { + return Some(&popup.terminal_id); + } + self.app + .find_pane(pane_id) + .map(|(_, pane)| &pane.attached_terminal_id) + } + + fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { + if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == pane_id) + { + return true; + } + let Some(workspace) = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + else { + return false; + }; + let Some(tab) = workspace.active_tab() else { + return false; + }; + if !tab.panes.contains_key(&pane_id) { + return false; + } + !tab.zoomed || tab.layout.focused() == pane_id + } + fn render_retained_pty_update_and_stream(&mut self) -> bool { crate::render_prof::event("retained.attempt"); let retained_started = crate::render_prof::timer(); @@ -4478,6 +4619,42 @@ mod tests { #[path = "pane_graphics.rs"] mod pane_graphics_tests; + #[test] + fn retained_render_plan_covers_each_render_path() { + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: true, + needs_graphics_render: true, + pty: PtyRenderState::Hidden, + }), + RetainedRenderPlan::Full + ); + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: true, + pty: PtyRenderState::Hidden, + }), + RetainedRenderPlan::Graphics + ); + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: false, + pty: PtyRenderState::Visible, + }), + RetainedRenderPlan::Pty + ); + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: false, + pty: PtyRenderState::Hidden, + }), + RetainedRenderPlan::HiddenPty + ); + } + fn test_headless_server() -> HeadlessServer { test_headless_server_with_event_hub(api::EventHub::default()) } @@ -4827,6 +5004,38 @@ mod tests { (server, client_rx, pane_id) } + fn hidden_pty_visibility_test_server( + client_sizes: &[(u16, u16)], + ) -> (HeadlessServer, crate::layout::PaneId) { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + for (index, &terminal_size) in client_sizes.iter().enumerate() { + let client_id = index as u64 + 1; + let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + client_id, + ClientConnection::new( + terminal_size, + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + client_id, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + } + + (server, background_pane) + } + fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { assert_eq!( (actual.width, actual.height), @@ -8076,6 +8285,117 @@ next_tab = "" ); } + #[test] + fn inactive_tab_pty_source_is_hidden_until_tab_focus() { + let (server, background_pane) = hidden_pty_visibility_test_server(&[]); + let sources = HashSet::from([background_pane]); + assert!(!server.pty_sources_visible_to_any_render_target(&sources)); + + let (mut server, background_pane) = + hidden_pty_visibility_test_server(&[(120, 40), (44, 20)]); + let sources = HashSet::from([background_pane]); + assert!(!server.pty_sources_visible_to_any_render_target(&sources)); + + server.app.state.workspaces[0].switch_tab(1); + assert!(server.pty_sources_visible_to_any_render_target(&sources)); + } + + #[tokio::test] + async fn hidden_pty_output_appears_after_switching_to_its_tab() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + workspace.insert_test_runtime( + background_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"before"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + server.render_and_stream(); + let _initial_frame = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial frame"); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, background_pane) + .expect("background runtime"); + runtime.test_process_pty_bytes(b"\rhidden-update"); + assert!(server.app.render_dirty.request_pty(background_pane)); + let request = server.app.render_dirty.take(); + let pty = if server.pty_sources_visible_to_any_render_target(&request.pty_sources) { + PtyRenderState::Visible + } else { + PtyRenderState::Hidden + }; + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: false, + pty, + }), + RetainedRenderPlan::HiddenPty + ); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + server.app.state.workspaces[0].switch_tab(background_tab); + server.render_and_stream(); + let visible_frame = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("frame after tab switch"), + ); + assert!(frame_text(&visible_frame).contains("hidden-update")); + } + + #[test] + fn direct_terminal_observer_keeps_hidden_pty_source_renderable() { + let (mut server, background_pane) = hidden_pty_visibility_test_server(&[]); + 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, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalObserve { terminal_id }, + None, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + false, + Some(client_tx), + ), + ); + + assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + } + #[tokio::test] async fn retained_pty_update_streams_dirty_row_from_last_frame() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 79ff63ca..b78cb6df 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -124,7 +124,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); diff --git a/src/terminal/id.rs b/src/terminal/id.rs index 53ef6786..c7310753 100644 --- a/src/terminal/id.rs +++ b/src/terminal/id.rs @@ -20,6 +20,10 @@ impl TerminalId { let counter = NEXT_TERMINAL_ID.fetch_add(1, Ordering::Relaxed); Self(format!("term_{micros:x}{counter:x}")) } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } } impl fmt::Display for TerminalId { diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index f9858ce0..d4b66f06 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -1,4 +1,6 @@ -use std::sync::{atomic::AtomicBool, Arc}; +use std::sync::Arc; + +use crate::render_signal::RenderSignal; use bytes::Bytes; use ratatui::{layout::Rect, Frame}; @@ -64,7 +66,7 @@ impl TerminalRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::from_handoff_fd( import, @@ -88,7 +90,7 @@ impl TerminalRuntime { launch_env: &crate::pane::PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn( pane_id, @@ -120,7 +122,7 @@ impl TerminalRuntime { initial_history_ansi: Option<&str>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn_with_initial_history( pane_id, @@ -153,7 +155,7 @@ impl TerminalRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn_shell_command( pane_id, @@ -186,7 +188,7 @@ impl TerminalRuntime { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn_argv_command( pane_id, diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 9575cfe9..84ce1d35 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -831,7 +831,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 299a152b..b7738992 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -1477,7 +1477,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 69193158..0a2b64ce 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -2258,7 +2258,7 @@ rows = [[{ token = "git_status", fg = "#123456" }]] &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); diff --git a/src/workspace.rs b/src/workspace.rs index bb079b96..23e35322 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::ops::{Deref, DerefMut}; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use ratatui::layout::Direction; @@ -12,6 +12,7 @@ use crate::layout::PaneId; #[cfg(test)] use crate::layout::TileLayout; use crate::pane::{PaneLaunchEnv, PaneState}; +use crate::render_signal::RenderSignal; use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; mod aggregate; @@ -234,7 +235,7 @@ impl Workspace { moved: MovedPane, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> Self { let id = generate_workspace_id(); let root_pane = moved.pane_id; @@ -278,7 +279,7 @@ impl Workspace { shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_extra_env( initial_cwd, @@ -304,7 +305,7 @@ impl Workspace { shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( @@ -333,7 +334,7 @@ impl Workspace { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_argv_command_with_extra_env( initial_cwd, @@ -359,7 +360,7 @@ impl Workspace { host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( @@ -387,7 +388,7 @@ impl Workspace { shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, argv: Option<&[String]>, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { @@ -1014,7 +1015,7 @@ impl Workspace { label: Option<String>, fallback_events: mpsc::Sender<AppEvent>, fallback_render_notify: Arc<Notify>, - fallback_render_dirty: Arc<AtomicBool>, + fallback_render_dirty: Arc<RenderSignal>, ) -> usize { let number = self.next_public_tab_number; self.next_public_tab_number += 1; @@ -1246,7 +1247,7 @@ impl Workspace { pub(crate) fn test_new(name: &str) -> Self { let (events, _) = mpsc::channel(64); let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(AtomicBool::new(false)); + let render_dirty = Arc::new(RenderSignal::new()); let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); let (layout, root_id) = TileLayout::new(); let terminal_id = TerminalId::alloc(); @@ -1304,7 +1305,7 @@ impl Workspace { pub(crate) fn test_add_tab(&mut self, name: Option<&str>) -> usize { let (events, _) = mpsc::channel(64); let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(AtomicBool::new(false)); + let render_dirty = Arc::new(RenderSignal::new()); let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new(TerminalId::alloc())); @@ -1653,7 +1654,7 @@ mod tests { let argv = vec![command.to_string()]; let (events, _) = mpsc::channel(64); let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(AtomicBool::new(false)); + let render_dirty = Arc::new(RenderSignal::new()); let (workspace, _terminal, runtime) = Workspace::new_argv_command( root.clone(), diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index e2f82741..55fa4c96 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::atomic::AtomicBool; use std::sync::Arc; use ratatui::layout::Direction; @@ -9,6 +8,7 @@ use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; use crate::pane::{PaneLaunchEnv, PaneState}; +use crate::render_signal::RenderSignal; use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; pub(crate) type DetachedPane = (PaneId, TerminalId); @@ -48,7 +48,7 @@ pub struct Tab { pub zoomed: bool, pub events: mpsc::Sender<AppEvent>, pub(crate) render_notify: Arc<Notify>, - pub(crate) render_dirty: Arc<AtomicBool>, + pub(crate) render_dirty: Arc<RenderSignal>, } impl Tab { @@ -63,7 +63,7 @@ impl Tab { launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, @@ -92,7 +92,7 @@ impl Tab { launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, @@ -122,7 +122,7 @@ impl Tab { launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, argv: Option<&[String]>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let (layout, root_id) = TileLayout::new(); @@ -439,7 +439,7 @@ impl Tab { moved: MovedPane, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, + render_dirty: Arc<RenderSignal>, ) -> Self { let mut panes = HashMap::new(); let pane_id = moved.pane_id; |
