par_term/app/window_state/impl_helpers.rs
1//! Helper and utility methods for `WindowState`, plus the `Drop` implementation.
2//!
3//! Covers:
4//! - DRY rendering helpers (`invalidate_tab_cache`, `request_redraw`, `clear_and_invalidate`)
5//! - Window access helpers (`with_window`, AUD-033)
6//! - Active-tab access helpers (`with_active_tab`, `with_active_tab_mut`, AUD-030)
7//! - Debounced config save (`save_config_debounced`, `process_pending_config_save`)
8//! - Anti-idle keep-alive logic
9//! - Shutdown sequence (`perform_shutdown`)
10//! - `Drop` implementation (fast-path window teardown)
11//!
12//! egui pointer / keyboard query helpers, modal-visibility queries, and scrollbar
13//! visibility logic have been extracted to `ui_query_helpers.rs`.
14
15use super::{ConfigSaveState, WindowState};
16use crate::app::window_state::anti_idle::should_send_keep_alive;
17use crate::tab::Tab;
18use anyhow::Result;
19use std::sync::Arc;
20
21impl WindowState {
22 // ========================================================================
23 // DRY Helper Methods
24 // ========================================================================
25
26 /// Invalidate the active tab's cell cache, forcing regeneration on next render
27 #[inline]
28 pub(crate) fn invalidate_tab_cache(&mut self) {
29 if let Some(tab) = self.tab_manager.active_tab_mut() {
30 tab.active_cache_mut().cells = None;
31 }
32 }
33
34 /// Request window redraw if window exists.
35 ///
36 /// Prefer this over the inline `if let Some(window) = &self.window { window.request_redraw() }`
37 /// pattern (AUD-032).
38 #[inline]
39 pub(crate) fn request_redraw(&self) {
40 if let Some(window) = &self.window {
41 crate::debug_trace!("REDRAW", "request_redraw called");
42 window.request_redraw();
43 } else {
44 crate::debug_trace!("REDRAW", "request_redraw called but no window");
45 }
46 }
47
48 /// Run a closure with the winit `Window`, returning `None` when the window is absent.
49 ///
50 /// Use this instead of the inline `if let Some(window) = &self.window { ... }` pattern
51 /// for one-shot operations on the window (cursor changes, title updates, etc.) (AUD-033).
52 ///
53 /// # Example
54 /// ```ignore
55 /// self.with_window(|w| w.set_cursor(cursor));
56 /// ```
57 #[inline]
58 pub(crate) fn with_window<R>(&self, f: impl FnOnce(&winit::window::Window) -> R) -> Option<R> {
59 self.window.as_deref().map(f)
60 }
61
62 /// Run a closure with the active tab (immutable), returning `None` when no tab is active.
63 ///
64 /// Use this instead of the inline `if let Some(tab) = self.tab_manager.active_tab() { ... }`
65 /// pattern (AUD-030).
66 #[inline]
67 pub(crate) fn with_active_tab<R>(&self, f: impl FnOnce(&Tab) -> R) -> Option<R> {
68 self.tab_manager.active_tab().map(f)
69 }
70
71 /// Run a closure with the active tab (mutable), returning `None` when no tab is active.
72 ///
73 /// Use this instead of the inline `if let Some(tab) = self.tab_manager.active_tab_mut() { ... }`
74 /// pattern (AUD-030).
75 #[inline]
76 pub(crate) fn with_active_tab_mut<R>(&mut self, f: impl FnOnce(&mut Tab) -> R) -> Option<R> {
77 self.tab_manager.active_tab_mut().map(f)
78 }
79
80 /// Clear renderer cells and invalidate cache (used when switching tabs)
81 pub(crate) fn clear_and_invalidate(&mut self) {
82 if let Some(renderer) = &mut self.renderer {
83 renderer.clear_all_cells();
84 }
85 self.invalidate_tab_cache();
86 self.focus_state.needs_redraw = true;
87 self.request_redraw();
88 }
89
90 // ========================================================================
91 // Debounced Config Save
92 // ========================================================================
93
94 /// Save config with debouncing to prevent rapid concurrent writes.
95 ///
96 /// Multiple code paths may request config saves in quick succession (e.g.,
97 /// user changes a setting, an agent modifies config, update checker records
98 /// timestamp). This method batches those saves together.
99 ///
100 /// - If called within DEBOUNCE_INTERVAL of last save, marks a pending save
101 /// and returns immediately (no error).
102 /// - If a save is already pending, just updates the pending flag (idempotent).
103 ///
104 /// Callers should invoke `process_pending_config_save()` periodically (e.g.,
105 /// once per frame) to flush any deferred saves.
106 pub(crate) fn save_config_debounced(&mut self) -> Result<()> {
107 let now = std::time::Instant::now();
108 let debounce_interval =
109 std::time::Duration::from_millis(ConfigSaveState::DEBOUNCE_INTERVAL_MS);
110
111 // Check if we're within the debounce window
112 if let Some(last_save) = self.render_loop.config_save.last_save
113 && now.duration_since(last_save) < debounce_interval
114 {
115 // Defer this save - mark as pending
116 self.render_loop.config_save.pending_save = true;
117 log::debug!(
118 "Config save debounced (within {}ms window)",
119 ConfigSaveState::DEBOUNCE_INTERVAL_MS
120 );
121 return Ok(());
122 }
123
124 // Perform the actual save
125 self.config.load().save()?;
126 self.render_loop.config_save.last_save = Some(now);
127 self.render_loop.config_save.pending_save = false;
128 log::debug!("Config saved immediately");
129 Ok(())
130 }
131
132 /// Process any pending config save that was deferred by debouncing.
133 ///
134 /// Should be called once per frame (e.g., in the render loop) to ensure
135 /// deferred saves are eventually flushed.
136 ///
137 /// Returns `true` if a save was performed, `false` if nothing was pending.
138 pub(crate) fn process_pending_config_save(&mut self) -> bool {
139 if !self.render_loop.config_save.pending_save {
140 return false;
141 }
142
143 let now = std::time::Instant::now();
144 let debounce_interval =
145 std::time::Duration::from_millis(ConfigSaveState::DEBOUNCE_INTERVAL_MS);
146
147 // Check if enough time has passed since last save
148 if let Some(last_save) = self.render_loop.config_save.last_save
149 && now.duration_since(last_save) < debounce_interval
150 {
151 // Still within debounce window, wait longer
152 return false;
153 }
154
155 // Perform the pending save
156 if let Err(e) = self.config.load().save() {
157 log::error!("Failed to save pending config: {}", e);
158 } else {
159 log::debug!("Pending config save flushed");
160 }
161
162 self.render_loop.config_save.last_save = Some(now);
163 self.render_loop.config_save.pending_save = false;
164 true
165 }
166
167 // ========================================================================
168 // Anti-idle
169 // ========================================================================
170
171 /// Check anti-idle timers and send keep-alive codes when due.
172 ///
173 /// Returns the next Instant when anti-idle should run, or None if disabled.
174 pub(crate) fn handle_anti_idle(
175 &mut self,
176 now: std::time::Instant,
177 ) -> Option<std::time::Instant> {
178 if !self.config.load().notifications.anti_idle_enabled {
179 return None;
180 }
181
182 let idle_threshold = std::time::Duration::from_secs(
183 self.config.load().notifications.anti_idle_seconds.max(1),
184 );
185 let keep_alive_code = [self.config.load().notifications.anti_idle_code];
186 let mut next_due: Option<std::time::Instant> = None;
187
188 for tab in self.tab_manager.tabs_mut() {
189 // Track activity on the terminal the user sees: the focused
190 // pane's (the daemon mirror in a mux tab) with a fallback to
191 // the tab terminal. Reading the hidden shell's generation in a
192 // mux tab would make the tab look permanently idle — and
193 // keep-alives belong to the daemon pane, where the SSH session
194 // the feature exists to keep alive actually runs.
195 let (focused_pane_id, terminal) = match tab.pane_manager().and_then(|pm| {
196 pm.focused_pane()
197 .map(|pane| (pane.id, Arc::clone(&pane.terminal)))
198 }) {
199 Some((pane_id, terminal)) => (Some(pane_id), terminal),
200 None => (None, Arc::clone(&tab.terminal)),
201 };
202
203 if let Ok(term) = terminal.try_read() {
204 // Treat new terminal output as activity
205 let current_generation = term.update_generation();
206 if current_generation > tab.activity.anti_idle_last_generation {
207 tab.activity.anti_idle_last_generation = current_generation;
208 tab.activity.anti_idle_last_activity = now;
209 }
210
211 // If idle long enough, send keep-alive code
212 if should_send_keep_alive(tab.activity.anti_idle_last_activity, now, idle_threshold)
213 {
214 // Routed through the free-function form: the tabs_mut
215 // iteration holds the mutable tab borrow, so a &self
216 // method call would not compile here.
217 let routed = focused_pane_id.is_some_and(|pane_id| {
218 crate::app::tmux_handler::pane_write::route_mux_pane_write_state(
219 &self.tmux_state,
220 tab.id,
221 pane_id,
222 &keep_alive_code,
223 )
224 });
225 if routed || term.write(&keep_alive_code).is_ok() {
226 tab.activity.anti_idle_last_activity = now;
227 } else {
228 log::warn!("Failed to send anti-idle keep-alive for tab {}", tab.id);
229 }
230 }
231
232 // Compute next due time for this tab
233 let elapsed = now.duration_since(tab.activity.anti_idle_last_activity);
234 let remaining = if elapsed >= idle_threshold {
235 idle_threshold
236 } else {
237 idle_threshold - elapsed
238 };
239 let candidate = now + remaining;
240 next_due = Some(next_due.map_or(candidate, |prev| prev.min(candidate)));
241 }
242 }
243
244 next_due
245 }
246
247 // ========================================================================
248 // Shutdown
249 // ========================================================================
250
251 /// Perform the shutdown sequence (save state and set shutdown flag)
252 pub(crate) fn perform_shutdown(&mut self) {
253 // Save last working directory for "previous session" mode
254 if self.config.load().shell.startup_directory_mode
255 == crate::config::StartupDirectoryMode::Previous
256 && let Some(tab) = self.tab_manager.active_tab()
257 && let Ok(term) = tab.terminal.try_read()
258 && let Some(cwd) = term.shell_integration_cwd()
259 {
260 log::info!("Saving last working directory: {}", cwd);
261 self.config.rcu(|old| {
262 let mut new = (**old).clone();
263 if let Err(e) = new.save_last_working_directory(&cwd) {
264 log::warn!("Failed to save last working directory: {}", e);
265 }
266 Arc::new(new)
267 });
268 }
269
270 // Set shutdown flag to stop redraw loop
271 self.is_shutting_down = true;
272 // Abort refresh tasks for all tabs
273 for tab in self.tab_manager.tabs_mut() {
274 if let Some(task) = tab.refresh_task.take() {
275 task.abort();
276 }
277 }
278 log::info!("Refresh tasks aborted, shutdown initiated");
279 }
280}
281
282// ---------------------------------------------------------------------------
283impl Drop for WindowState {
284 fn drop(&mut self) {
285 let t0 = std::time::Instant::now();
286 log::info!("Shutting down window (fast path)");
287
288 // Signal status bar polling threads to stop immediately.
289 // They check the flag every 50ms, so by the time the auto-drop
290 // calls join() later, the threads will already be exiting.
291 self.status_bar_ui.signal_shutdown();
292
293 // Save command history on a background thread (serializes in-memory, writes async)
294 self.overlay_ui.command_history.save_background();
295
296 // Set shutdown flag
297 self.is_shutting_down = true;
298
299 // Hide the window immediately for instant visual feedback
300 if let Some(ref window) = self.window {
301 window.set_visible(false);
302 log::info!(
303 "Window hidden for instant visual close (+{:.1}ms)",
304 t0.elapsed().as_secs_f64() * 1000.0
305 );
306 }
307
308 // Clean up egui state FIRST before any other resources are dropped
309 self.egui.state = None;
310 self.egui.ctx = None;
311
312 // Drain all tabs from the manager (takes ownership without dropping)
313 let mut tabs = self.tab_manager.drain_tabs();
314 let tab_count = tabs.len();
315 log::info!(
316 "Fast shutdown: draining {} tabs (+{:.1}ms)",
317 tab_count,
318 t0.elapsed().as_secs_f64() * 1000.0
319 );
320
321 // Collect terminal Arcs and session loggers from all tabs and panes
322 // BEFORE setting shutdown_fast. Cloning the Arc keeps TerminalManager
323 // alive even after Tab/Pane is dropped. Session loggers are collected
324 // so they can be stopped on a background thread instead of blocking.
325 let mut terminal_arcs = Vec::new();
326 let mut session_loggers = Vec::new();
327
328 for tab in &mut tabs {
329 // Stop refresh tasks (fast - just aborts tokio tasks)
330 tab.stop_refresh_task();
331
332 // Collect session logger for background stop
333 session_loggers.push(Arc::clone(&tab.session_logger));
334
335 // Clone terminal Arc before we mark shutdown_fast
336 terminal_arcs.push(Arc::clone(&tab.terminal));
337
338 // Also handle panes if this tab has splits
339 if let Some(ref mut pm) = tab.pane_manager {
340 for pane in pm.all_panes_mut() {
341 pane.stop_refresh_task();
342 session_loggers.push(Arc::clone(&pane.session_logger));
343 terminal_arcs.push(Arc::clone(&pane.terminal));
344 pane.shutdown_fast = true;
345 }
346 }
347
348 // Mark tab for fast drop (skips sleep + kill in Tab::drop)
349 tab.shutdown_fast = true;
350 }
351
352 // Pre-kill all PTY processes (sends SIGKILL, fast non-blocking)
353 for arc in &terminal_arcs {
354 if let Ok(mut term) = arc.try_write()
355 && term.is_running()
356 {
357 let _ = term.kill();
358 }
359 }
360 log::info!(
361 "Pre-killed {} terminal sessions (+{:.1}ms)",
362 terminal_arcs.len(),
363 t0.elapsed().as_secs_f64() * 1000.0
364 );
365
366 // Drop tabs on main thread (fast - Tab::drop just returns immediately)
367 drop(tabs);
368 log::info!(
369 "Tabs dropped (+{:.1}ms)",
370 t0.elapsed().as_secs_f64() * 1000.0
371 );
372
373 // Fire-and-forget: stop session loggers on a background thread.
374 // Each logger.stop() flushes buffered I/O which can block.
375 if !session_loggers.is_empty() {
376 let _ = std::thread::Builder::new()
377 .name("logger-cleanup".into())
378 .spawn(move || {
379 for logger_arc in session_loggers {
380 if let Some(ref mut logger) = *logger_arc.lock() {
381 let _ = logger.stop();
382 }
383 }
384 });
385 }
386
387 // Fire-and-forget: drop the cloned terminal Arcs on background threads.
388 // When our clone is the last reference, TerminalManager::drop runs,
389 // which triggers PtySession::drop (up to 2s reader thread wait).
390 // By running these in parallel, all sessions clean up concurrently.
391 // We intentionally do NOT join these threads — the process is exiting
392 // and the OS will reclaim all resources.
393 for (i, arc) in terminal_arcs.into_iter().enumerate() {
394 let _ = std::thread::Builder::new()
395 .name(format!("pty-cleanup-{}", i))
396 .spawn(move || {
397 let t = std::time::Instant::now();
398 drop(arc);
399 log::info!(
400 "pty-cleanup-{} finished in {:.1}ms",
401 i,
402 t.elapsed().as_secs_f64() * 1000.0
403 );
404 });
405 }
406
407 log::info!(
408 "Window shutdown complete ({} tabs, main thread blocked {:.1}ms)",
409 tab_count,
410 t0.elapsed().as_secs_f64() * 1000.0
411 );
412 }
413}