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 if let Ok(term) = tab.terminal.try_read() {
190 // Treat new terminal output as activity
191 let current_generation = term.update_generation();
192 if current_generation > tab.activity.anti_idle_last_generation {
193 tab.activity.anti_idle_last_generation = current_generation;
194 tab.activity.anti_idle_last_activity = now;
195 }
196
197 // If idle long enough, send keep-alive code
198 if should_send_keep_alive(tab.activity.anti_idle_last_activity, now, idle_threshold)
199 {
200 if let Err(e) = term.write(&keep_alive_code) {
201 log::warn!(
202 "Failed to send anti-idle keep-alive for tab {}: {}",
203 tab.id,
204 e
205 );
206 } else {
207 tab.activity.anti_idle_last_activity = now;
208 }
209 }
210
211 // Compute next due time for this tab
212 let elapsed = now.duration_since(tab.activity.anti_idle_last_activity);
213 let remaining = if elapsed >= idle_threshold {
214 idle_threshold
215 } else {
216 idle_threshold - elapsed
217 };
218 let candidate = now + remaining;
219 next_due = Some(next_due.map_or(candidate, |prev| prev.min(candidate)));
220 }
221 }
222
223 next_due
224 }
225
226 // ========================================================================
227 // Shutdown
228 // ========================================================================
229
230 /// Perform the shutdown sequence (save state and set shutdown flag)
231 pub(crate) fn perform_shutdown(&mut self) {
232 // Save last working directory for "previous session" mode
233 if self.config.load().shell.startup_directory_mode
234 == crate::config::StartupDirectoryMode::Previous
235 && let Some(tab) = self.tab_manager.active_tab()
236 && let Ok(term) = tab.terminal.try_read()
237 && let Some(cwd) = term.shell_integration_cwd()
238 {
239 log::info!("Saving last working directory: {}", cwd);
240 self.config.rcu(|old| {
241 let mut new = (**old).clone();
242 if let Err(e) = new.save_last_working_directory(&cwd) {
243 log::warn!("Failed to save last working directory: {}", e);
244 }
245 Arc::new(new)
246 });
247 }
248
249 // Set shutdown flag to stop redraw loop
250 self.is_shutting_down = true;
251 // Abort refresh tasks for all tabs
252 for tab in self.tab_manager.tabs_mut() {
253 if let Some(task) = tab.refresh_task.take() {
254 task.abort();
255 }
256 }
257 log::info!("Refresh tasks aborted, shutdown initiated");
258 }
259}
260
261// ---------------------------------------------------------------------------
262impl Drop for WindowState {
263 fn drop(&mut self) {
264 let t0 = std::time::Instant::now();
265 log::info!("Shutting down window (fast path)");
266
267 // Signal status bar polling threads to stop immediately.
268 // They check the flag every 50ms, so by the time the auto-drop
269 // calls join() later, the threads will already be exiting.
270 self.status_bar_ui.signal_shutdown();
271
272 // Save command history on a background thread (serializes in-memory, writes async)
273 self.overlay_ui.command_history.save_background();
274
275 // Set shutdown flag
276 self.is_shutting_down = true;
277
278 // Hide the window immediately for instant visual feedback
279 if let Some(ref window) = self.window {
280 window.set_visible(false);
281 log::info!(
282 "Window hidden for instant visual close (+{:.1}ms)",
283 t0.elapsed().as_secs_f64() * 1000.0
284 );
285 }
286
287 // Clean up egui state FIRST before any other resources are dropped
288 self.egui.state = None;
289 self.egui.ctx = None;
290
291 // Drain all tabs from the manager (takes ownership without dropping)
292 let mut tabs = self.tab_manager.drain_tabs();
293 let tab_count = tabs.len();
294 log::info!(
295 "Fast shutdown: draining {} tabs (+{:.1}ms)",
296 tab_count,
297 t0.elapsed().as_secs_f64() * 1000.0
298 );
299
300 // Collect terminal Arcs and session loggers from all tabs and panes
301 // BEFORE setting shutdown_fast. Cloning the Arc keeps TerminalManager
302 // alive even after Tab/Pane is dropped. Session loggers are collected
303 // so they can be stopped on a background thread instead of blocking.
304 let mut terminal_arcs = Vec::new();
305 let mut session_loggers = Vec::new();
306
307 for tab in &mut tabs {
308 // Stop refresh tasks (fast - just aborts tokio tasks)
309 tab.stop_refresh_task();
310
311 // Collect session logger for background stop
312 session_loggers.push(Arc::clone(&tab.session_logger));
313
314 // Clone terminal Arc before we mark shutdown_fast
315 terminal_arcs.push(Arc::clone(&tab.terminal));
316
317 // Also handle panes if this tab has splits
318 if let Some(ref mut pm) = tab.pane_manager {
319 for pane in pm.all_panes_mut() {
320 pane.stop_refresh_task();
321 session_loggers.push(Arc::clone(&pane.session_logger));
322 terminal_arcs.push(Arc::clone(&pane.terminal));
323 pane.shutdown_fast = true;
324 }
325 }
326
327 // Mark tab for fast drop (skips sleep + kill in Tab::drop)
328 tab.shutdown_fast = true;
329 }
330
331 // Pre-kill all PTY processes (sends SIGKILL, fast non-blocking)
332 for arc in &terminal_arcs {
333 if let Ok(mut term) = arc.try_write()
334 && term.is_running()
335 {
336 let _ = term.kill();
337 }
338 }
339 log::info!(
340 "Pre-killed {} terminal sessions (+{:.1}ms)",
341 terminal_arcs.len(),
342 t0.elapsed().as_secs_f64() * 1000.0
343 );
344
345 // Drop tabs on main thread (fast - Tab::drop just returns immediately)
346 drop(tabs);
347 log::info!(
348 "Tabs dropped (+{:.1}ms)",
349 t0.elapsed().as_secs_f64() * 1000.0
350 );
351
352 // Fire-and-forget: stop session loggers on a background thread.
353 // Each logger.stop() flushes buffered I/O which can block.
354 if !session_loggers.is_empty() {
355 let _ = std::thread::Builder::new()
356 .name("logger-cleanup".into())
357 .spawn(move || {
358 for logger_arc in session_loggers {
359 if let Some(ref mut logger) = *logger_arc.lock() {
360 let _ = logger.stop();
361 }
362 }
363 });
364 }
365
366 // Fire-and-forget: drop the cloned terminal Arcs on background threads.
367 // When our clone is the last reference, TerminalManager::drop runs,
368 // which triggers PtySession::drop (up to 2s reader thread wait).
369 // By running these in parallel, all sessions clean up concurrently.
370 // We intentionally do NOT join these threads — the process is exiting
371 // and the OS will reclaim all resources.
372 for (i, arc) in terminal_arcs.into_iter().enumerate() {
373 let _ = std::thread::Builder::new()
374 .name(format!("pty-cleanup-{}", i))
375 .spawn(move || {
376 let t = std::time::Instant::now();
377 drop(arc);
378 log::info!(
379 "pty-cleanup-{} finished in {:.1}ms",
380 i,
381 t.elapsed().as_secs_f64() * 1000.0
382 );
383 });
384 }
385
386 log::info!(
387 "Window shutdown complete ({} tabs, main thread blocked {:.1}ms)",
388 tab_count,
389 t0.elapsed().as_secs_f64() * 1000.0
390 );
391 }
392}