Skip to main content

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}