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            // 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}