par_term/app/
handler.rs

1//! Application event handler
2//!
3//! This module implements the winit `ApplicationHandler` trait for `WindowManager`,
4//! routing window events to the appropriate `WindowState` and handling menu events.
5
6use crate::app::window_manager::WindowManager;
7use crate::app::window_state::WindowState;
8use std::sync::Arc;
9use winit::application::ApplicationHandler;
10use winit::event::WindowEvent;
11use winit::event_loop::{ActiveEventLoop, ControlFlow};
12use winit::window::WindowId;
13
14impl WindowState {
15    pub(crate) fn check_notifications(&mut self) {
16        let tab = if let Some(t) = self.tab_manager.active_tab() {
17            t
18        } else {
19            return;
20        };
21
22        if let Ok(term) = tab.terminal.try_lock() {
23            // Check for OSC 9/777 notifications
24            if term.has_notifications() {
25                let notifications = term.take_notifications();
26                for notif in notifications {
27                    self.deliver_notification(&notif.title, &notif.message);
28                }
29            }
30        }
31    }
32
33    pub(crate) fn check_bell(&mut self) {
34        // Skip if all bell notifications are disabled
35        if self.config.notification_bell_sound == 0
36            && !self.config.notification_bell_visual
37            && !self.config.notification_bell_desktop
38        {
39            return;
40        }
41
42        // Get current bell count from active tab's terminal
43        let (current_bell_count, last_count) = {
44            let tab = if let Some(t) = self.tab_manager.active_tab() {
45                t
46            } else {
47                return;
48            };
49
50            if let Ok(term) = tab.terminal.try_lock() {
51                (term.bell_count(), tab.bell.last_count)
52            } else {
53                return;
54            }
55        };
56
57        if current_bell_count > last_count {
58            // Bell event(s) occurred
59            let bell_events = current_bell_count - last_count;
60            log::info!("Bell event detected ({} bell(s))", bell_events);
61            log::info!(
62                "  Config: sound={}, visual={}, desktop={}",
63                self.config.notification_bell_sound,
64                self.config.notification_bell_visual,
65                self.config.notification_bell_desktop
66            );
67
68            // Play audio bell if enabled (volume > 0)
69            if self.config.notification_bell_sound > 0 {
70                if let Some(tab) = self.tab_manager.active_tab() {
71                    if let Some(ref audio_bell) = tab.bell.audio {
72                        log::info!(
73                            "  Playing audio bell at {}% volume",
74                            self.config.notification_bell_sound
75                        );
76                        audio_bell.play(self.config.notification_bell_sound);
77                    } else {
78                        log::warn!("  Audio bell requested but not initialized");
79                    }
80                }
81            } else {
82                log::debug!("  Audio bell disabled (volume=0)");
83            }
84
85            // Trigger visual bell flash if enabled
86            if self.config.notification_bell_visual {
87                log::info!("  Triggering visual bell flash");
88                if let Some(tab) = self.tab_manager.active_tab_mut() {
89                    tab.bell.visual_flash = Some(std::time::Instant::now());
90                }
91                // Request immediate redraw to show flash
92                if let Some(window) = &self.window {
93                    window.request_redraw();
94                }
95            } else {
96                log::debug!("  Visual bell disabled");
97            }
98
99            // Send desktop notification if enabled
100            if self.config.notification_bell_desktop {
101                log::info!("  Sending desktop notification");
102                let message = if bell_events == 1 {
103                    "Terminal bell".to_string()
104                } else {
105                    format!("Terminal bell ({} events)", bell_events)
106                };
107                self.deliver_notification("Terminal", &message);
108            } else {
109                log::debug!("  Desktop notification disabled");
110            }
111
112            // Update last count
113            if let Some(tab) = self.tab_manager.active_tab_mut() {
114                tab.bell.last_count = current_bell_count;
115            }
116        }
117    }
118
119    #[allow(dead_code)]
120    fn take_screenshot(&self) {
121        log::info!("Taking screenshot...");
122
123        let terminal = if let Some(tab) = self.tab_manager.active_tab() {
124            Arc::clone(&tab.terminal)
125        } else {
126            log::warn!("No terminal available for screenshot");
127            self.deliver_notification("Screenshot Error", "No terminal available");
128            return;
129        };
130
131        // Generate timestamp-based filename
132        let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
133        let format = &self.config.screenshot_format;
134        let filename = format!("par-term_screenshot_{}.{}", timestamp, format);
135
136        // Create screenshots directory in user's home dir
137        if let Some(home_dir) = dirs::home_dir() {
138            let screenshot_dir = home_dir.join("par-term-screenshots");
139            if !screenshot_dir.exists()
140                && let Err(e) = std::fs::create_dir_all(&screenshot_dir)
141            {
142                log::error!("Failed to create screenshot directory: {}", e);
143                self.deliver_notification(
144                    "Screenshot Error",
145                    &format!("Failed to create directory: {}", e),
146                );
147                return;
148            }
149
150            let path = screenshot_dir.join(&filename);
151            let path_str = path.to_string_lossy().to_string();
152
153            // Take screenshot (include scrollback for better context)
154            let terminal_clone = terminal;
155            let format_clone = format.clone();
156
157            // Use async to avoid blocking the UI
158            let result = std::thread::spawn(move || {
159                if let Ok(term) = terminal_clone.try_lock() {
160                    // Include 0 scrollback lines (just visible content)
161                    term.screenshot_to_file(&path, &format_clone, 0)
162                } else {
163                    Err(anyhow::anyhow!("Failed to lock terminal"))
164                }
165            })
166            .join();
167
168            match result {
169                Ok(Ok(())) => {
170                    log::info!("Screenshot saved to: {}", path_str);
171                    self.deliver_notification(
172                        "Screenshot Saved",
173                        &format!("Saved to: {}", path_str),
174                    );
175                }
176                Ok(Err(e)) => {
177                    log::error!("Failed to save screenshot: {}", e);
178                    self.deliver_notification(
179                        "Screenshot Error",
180                        &format!("Failed to save: {}", e),
181                    );
182                }
183                Err(e) => {
184                    log::error!("Screenshot thread panicked: {:?}", e);
185                    self.deliver_notification("Screenshot Error", "Screenshot thread failed");
186                }
187            }
188        } else {
189            log::error!("Failed to get home directory");
190            self.deliver_notification("Screenshot Error", "Failed to get home directory");
191        }
192    }
193
194    // TODO: Recording APIs not yet available in par-term-emu-core-rust
195    // Uncomment when the core library supports recording again
196    #[allow(dead_code)]
197    fn toggle_recording(&mut self) {
198        self.deliver_notification(
199            "Recording Not Available",
200            "Recording APIs are not yet implemented in the core library",
201        );
202    }
203
204    pub(crate) fn deliver_notification(&self, title: &str, message: &str) {
205        // Always log notifications
206        if !title.is_empty() {
207            log::info!("=== Notification: {} ===", title);
208            log::info!("{}", message);
209            log::info!("===========================");
210        } else {
211            log::info!("=== Notification ===");
212            log::info!("{}", message);
213            log::info!("===================");
214        }
215
216        // Send desktop notification if enabled
217        #[cfg(not(target_os = "macos"))]
218        {
219            use notify_rust::Notification;
220            let notification_title = if !title.is_empty() {
221                title
222            } else {
223                "Terminal Notification"
224            };
225
226            if let Err(e) = Notification::new()
227                .summary(notification_title)
228                .body(message)
229                .timeout(notify_rust::Timeout::Milliseconds(3000))
230                .show()
231            {
232                log::warn!("Failed to send desktop notification: {}", e);
233            }
234        }
235
236        #[cfg(target_os = "macos")]
237        {
238            // macOS notifications via osascript
239            let notification_title = if !title.is_empty() {
240                title
241            } else {
242                "Terminal Notification"
243            };
244
245            // Escape quotes in title and message for AppleScript
246            let escaped_title = notification_title.replace('"', "\\\"");
247            let escaped_message = message.replace('"', "\\\"");
248
249            // Use osascript to display notification
250            let script = format!(
251                r#"display notification "{}" with title "{}""#,
252                escaped_message, escaped_title
253            );
254
255            if let Err(e) = std::process::Command::new("osascript")
256                .arg("-e")
257                .arg(&script)
258                .output()
259            {
260                log::warn!("Failed to send macOS desktop notification: {}", e);
261            }
262        }
263    }
264
265    /// Update window title with shell integration info (cwd and exit code)
266    /// Only updates if not scrolled and not hovering over URL
267    pub(crate) fn update_window_title_with_shell_integration(&self) {
268        // Get active tab state
269        let tab = if let Some(t) = self.tab_manager.active_tab() {
270            t
271        } else {
272            return;
273        };
274
275        // Skip if scrolled (scrollback indicator takes priority)
276        if tab.scroll_state.offset != 0 {
277            return;
278        }
279
280        // Skip if hovering over URL (URL tooltip takes priority)
281        if tab.mouse.hovered_url.is_some() {
282            return;
283        }
284
285        // Skip if window not available
286        let window = if let Some(w) = &self.window {
287            w
288        } else {
289            return;
290        };
291
292        // Try to get shell integration info
293        if let Ok(term) = tab.terminal.try_lock() {
294            let mut title_parts = vec![self.config.window_title.clone()];
295
296            // Add current working directory if available
297            if let Some(cwd) = term.shell_integration_cwd() {
298                // Abbreviate home directory to ~
299                let abbreviated_cwd = if let Some(home) = dirs::home_dir() {
300                    cwd.replace(&home.to_string_lossy().to_string(), "~")
301                } else {
302                    cwd
303                };
304                title_parts.push(format!("({})", abbreviated_cwd));
305            }
306
307            // Add exit code indicator if last command failed
308            if let Some(exit_code) = term.shell_integration_exit_code()
309                && exit_code != 0
310            {
311                title_parts.push(format!("[Exit: {}]", exit_code));
312            }
313
314            // Add recording indicator
315            if self.is_recording {
316                title_parts.push("[RECORDING]".to_string());
317            }
318
319            // Build and set title
320            let title = title_parts.join(" ");
321            window.set_title(&title);
322        }
323    }
324
325    /// Handle window events for this window state
326    pub(crate) fn handle_window_event(
327        &mut self,
328        event_loop: &ActiveEventLoop,
329        event: WindowEvent,
330    ) -> bool {
331        use winit::keyboard::{Key, NamedKey};
332
333        // Debug: Log ALL keyboard events at entry to diagnose Space issue
334        if let WindowEvent::KeyboardInput {
335            event: key_event, ..
336        } = &event
337        {
338            match &key_event.logical_key {
339                Key::Character(s) => {
340                    log::trace!(
341                        "window_event: Character '{}', state={:?}",
342                        s,
343                        key_event.state
344                    );
345                }
346                Key::Named(NamedKey::Space) => {
347                    log::debug!("SPACE EVENT: state={:?}", key_event.state);
348                }
349                Key::Named(named) => {
350                    log::trace!(
351                        "window_event: Named key {:?}, state={:?}",
352                        named,
353                        key_event.state
354                    );
355                }
356                other => {
357                    log::trace!(
358                        "window_event: Other key {:?}, state={:?}",
359                        other,
360                        key_event.state
361                    );
362                }
363            }
364        }
365
366        // Let egui handle the event (needed for proper rendering state)
367        let egui_consumed =
368            if let (Some(egui_state), Some(window)) = (&mut self.egui_state, &self.window) {
369                let event_response = egui_state.on_window_event(window, &event);
370                event_response.consumed
371            } else {
372                false
373            };
374
375        // Debug: Log when egui consumes events but we ignore it
376        if egui_consumed
377            && !self.settings_ui.visible
378            && let WindowEvent::KeyboardInput {
379                event: key_event, ..
380            } = &event
381            && let Key::Named(NamedKey::Space) = &key_event.logical_key
382        {
383            log::debug!("egui tried to consume Space (UI closed, ignoring)");
384        }
385
386        // Only honor egui's consumption if an egui UI panel is actually visible
387        // This prevents egui from stealing Tab/Space when UI is closed
388        let any_ui_visible =
389            self.settings_ui.visible || self.help_ui.visible || self.clipboard_history_ui.visible;
390        if egui_consumed
391            && any_ui_visible
392            && !matches!(
393                event,
394                WindowEvent::CloseRequested | WindowEvent::RedrawRequested
395            )
396        {
397            if let WindowEvent::KeyboardInput {
398                event: key_event, ..
399            } = &event
400            {
401                match &key_event.logical_key {
402                    Key::Named(NamedKey::Space) => {
403                        log::debug!("egui consumed Space while UI panel is visible")
404                    }
405                    Key::Named(_) => {
406                        log::debug!("egui consumed named key while UI panel is visible")
407                    }
408                    _ => {}
409                }
410            }
411            return false; // Event consumed by egui, don't close window
412        }
413
414        match event {
415            WindowEvent::CloseRequested => {
416                log::info!("Close requested for window");
417                // Set shutdown flag to stop redraw loop
418                self.is_shutting_down = true;
419                // Abort refresh tasks for all tabs
420                for tab in self.tab_manager.tabs_mut() {
421                    if let Some(task) = tab.refresh_task.take() {
422                        task.abort();
423                    }
424                }
425                log::info!("Refresh tasks aborted");
426                return true; // Signal to close this window
427            }
428
429            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
430                if let (Some(renderer), Some(window)) = (&mut self.renderer, &self.window) {
431                    log::info!(
432                        "Scale factor changed to {} (display change detected)",
433                        scale_factor
434                    );
435
436                    let size = window.inner_size();
437                    let (cols, rows) = renderer.handle_scale_factor_change(scale_factor, size);
438
439                    // Reconfigure surface after scale factor change
440                    // This is important when dragging between displays with different DPIs
441                    renderer.reconfigure_surface();
442
443                    // Calculate pixel dimensions
444                    let cell_width = renderer.cell_width();
445                    let cell_height = renderer.cell_height();
446                    let width_px = (cols as f32 * cell_width) as usize;
447                    let height_px = (rows as f32 * cell_height) as usize;
448
449                    // Resize all tabs' terminals with pixel dimensions for TIOCGWINSZ support
450                    for tab in self.tab_manager.tabs_mut() {
451                        if let Ok(mut term) = tab.terminal.try_lock() {
452                            let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
453                        }
454                    }
455
456                    // Reconfigure macOS Metal layer after display change
457                    #[cfg(target_os = "macos")]
458                    {
459                        if let Err(e) =
460                            crate::macos_metal::configure_metal_layer_for_performance(window)
461                        {
462                            log::warn!(
463                                "Failed to reconfigure Metal layer after display change: {}",
464                                e
465                            );
466                        }
467                    }
468
469                    // Request redraw to apply changes
470                    window.request_redraw();
471                }
472            }
473
474            // Handle window moved - surface may become invalid when moving between monitors
475            WindowEvent::Moved(_) => {
476                if let (Some(renderer), Some(window)) = (&mut self.renderer, &self.window) {
477                    log::debug!(
478                        "Window moved - reconfiguring surface for potential display change"
479                    );
480
481                    // Reconfigure surface to handle potential display changes
482                    // This catches cases where displays have same DPI but different surface properties
483                    renderer.reconfigure_surface();
484
485                    // On macOS, reconfigure the Metal layer for the potentially new display
486                    #[cfg(target_os = "macos")]
487                    {
488                        if let Err(e) =
489                            crate::macos_metal::configure_metal_layer_for_performance(window)
490                        {
491                            log::warn!(
492                                "Failed to reconfigure Metal layer after window move: {}",
493                                e
494                            );
495                        }
496                    }
497
498                    // Request redraw to ensure proper rendering on new display
499                    window.request_redraw();
500                }
501            }
502
503            WindowEvent::Resized(physical_size) => {
504                if let Some(renderer) = &mut self.renderer {
505                    let (cols, rows) = renderer.resize(physical_size);
506
507                    // Calculate text area pixel dimensions
508                    let cell_width = renderer.cell_width();
509                    let cell_height = renderer.cell_height();
510                    let width_px = (cols as f32 * cell_width) as usize;
511                    let height_px = (rows as f32 * cell_height) as usize;
512
513                    // Resize all tabs' terminals with pixel dimensions for TIOCGWINSZ support
514                    // This allows applications like kitty icat to query pixel dimensions
515                    // Note: The core library (v0.11.0+) implements scrollback reflow when
516                    // width changes - wrapped lines are unwrapped/re-wrapped as needed.
517                    for tab in self.tab_manager.tabs_mut() {
518                        if let Ok(mut term) = tab.terminal.try_lock() {
519                            let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
520                            tab.cache.scrollback_len = term.scrollback_len();
521                        }
522                        // Invalidate cell cache to force regeneration
523                        tab.cache.cells = None;
524                    }
525
526                    // Update scrollbar for active tab
527                    if let Some(tab) = self.tab_manager.active_tab() {
528                        let total_lines = rows + tab.cache.scrollback_len;
529                        renderer.update_scrollbar(tab.scroll_state.offset, rows, total_lines);
530                    }
531                }
532            }
533
534            WindowEvent::KeyboardInput { event, .. } => {
535                self.handle_key_event(event, event_loop);
536            }
537
538            WindowEvent::ModifiersChanged(modifiers) => {
539                self.input_handler.update_modifiers(modifiers);
540            }
541
542            WindowEvent::MouseWheel { delta, .. } => {
543                // Skip if egui UI is handling mouse
544                if !self.is_egui_using_pointer() {
545                    self.handle_mouse_wheel(delta);
546                }
547            }
548
549            WindowEvent::MouseInput { button, state, .. } => {
550                // Skip if egui UI is handling mouse
551                if !self.is_egui_using_pointer() {
552                    self.handle_mouse_button(button, state);
553                }
554            }
555
556            WindowEvent::CursorMoved { position, .. } => {
557                // Skip if egui UI is handling mouse
558                if !self.is_egui_using_pointer() {
559                    self.handle_mouse_move((position.x, position.y));
560                }
561            }
562
563            WindowEvent::RedrawRequested => {
564                // Skip rendering if shutting down
565                if self.is_shutting_down {
566                    return false;
567                }
568
569                // Check if active tab's shell has exited and close window/tab if configured
570                if self.config.exit_on_shell_exit {
571                    // First check if shell exited (gather info without mutable borrows)
572                    let (shell_exited, active_tab_id, tab_count) = {
573                        let exited = self.tab_manager.active_tab().is_some_and(|tab| {
574                            tab.terminal
575                                .try_lock()
576                                .ok()
577                                .is_some_and(|term| !term.is_running())
578                        });
579                        let tab_id = self.tab_manager.active_tab_id();
580                        let count = self.tab_manager.tab_count();
581                        (exited, tab_id, count)
582                    };
583
584                    if shell_exited {
585                        log::info!("Shell in active tab has exited");
586                        if tab_count <= 1 {
587                            // Last tab - close window
588                            log::info!("Last tab, closing window");
589                            self.is_shutting_down = true;
590                            for tab in self.tab_manager.tabs_mut() {
591                                tab.stop_refresh_task();
592                            }
593                            return true;
594                        } else if let Some(tab_id) = active_tab_id {
595                            // Close just this tab
596                            let _ = self.tab_manager.close_tab(tab_id);
597                        }
598                    }
599                }
600
601                self.render();
602            }
603
604            _ => {}
605        }
606
607        false // Don't close window
608    }
609
610    /// Process per-window updates in about_to_wait
611    pub(crate) fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
612        // Skip all processing if shutting down
613        if self.is_shutting_down {
614            return;
615        }
616
617        // Check for and deliver notifications (OSC 9/777)
618        self.check_notifications();
619
620        // Check for bell events and play audio/visual feedback
621        self.check_bell();
622
623        // Update window title with shell integration info (CWD, exit code)
624        self.update_window_title_with_shell_integration();
625
626        // --- POWER SAVING & SMART REDRAW LOGIC ---
627        // We use ControlFlow::WaitUntil to sleep until the next expected event.
628        // This drastically reduces CPU/GPU usage compared to continuous polling (ControlFlow::Poll).
629        // The loop calculates the earliest time any component needs to update.
630
631        let now = std::time::Instant::now();
632        let mut next_wake = now + std::time::Duration::from_secs(1); // Default sleep for 1s of inactivity
633
634        // 1. Cursor Blinking
635        // Wake up exactly when the cursor needs to toggle visibility or fade.
636        if self.config.cursor_blink {
637            if self.cursor_blink_timer.is_none() {
638                let blink_interval =
639                    std::time::Duration::from_millis(self.config.cursor_blink_interval);
640                self.cursor_blink_timer = Some(now + blink_interval);
641            }
642
643            if let Some(next_blink) = self.cursor_blink_timer {
644                if now >= next_blink {
645                    // Time to toggle: trigger redraw and schedule next phase
646                    self.needs_redraw = true;
647                    let blink_interval =
648                        std::time::Duration::from_millis(self.config.cursor_blink_interval);
649                    self.cursor_blink_timer = Some(now + blink_interval);
650                } else if next_blink < next_wake {
651                    // Schedule wake-up for the next toggle
652                    next_wake = next_blink;
653                }
654            }
655        }
656
657        // 2. Smooth Scrolling & Animations
658        // If a scroll interpolation or terminal animation is active, target ~60 FPS (16.6ms).
659        if let Some(tab) = self.tab_manager.active_tab() {
660            if tab.scroll_state.animation_start.is_some() {
661                self.needs_redraw = true;
662                let next_frame = now + std::time::Duration::from_millis(16);
663                if next_frame < next_wake {
664                    next_wake = next_frame;
665                }
666            }
667
668            // 3. Visual Bell Feedback
669            // Maintain high frame rate during the visual flash fade-out.
670            if tab.bell.visual_flash.is_some() {
671                self.needs_redraw = true;
672                let next_frame = now + std::time::Duration::from_millis(16);
673                if next_frame < next_wake {
674                    next_wake = next_frame;
675                }
676            }
677
678            // 4. Interactive UI Elements
679            // Ensure high responsiveness during mouse dragging (text selection or scrollbar).
680            if (tab.mouse.is_selecting
681                || tab.mouse.selection.is_some()
682                || tab.scroll_state.dragging)
683                && tab.mouse.button_pressed
684            {
685                self.needs_redraw = true;
686            }
687        }
688
689        // 5. Custom Background Shaders
690        // If a custom shader is animated, we must render continuously at high FPS.
691        if let Some(renderer) = &self.renderer
692            && renderer.needs_continuous_render()
693        {
694            self.needs_redraw = true;
695            let next_frame = now + std::time::Duration::from_millis(16);
696            if next_frame < next_wake {
697                next_wake = next_frame;
698            }
699        }
700
701        // --- TRIGGER REDRAW ---
702        // Request a redraw if any of the logic above determined an update is due.
703        if self.needs_redraw
704            && let Some(window) = &self.window
705        {
706            window.request_redraw();
707            self.needs_redraw = false;
708        }
709
710        // Set the calculated sleep interval
711        event_loop.set_control_flow(ControlFlow::WaitUntil(next_wake));
712    }
713}
714
715impl ApplicationHandler for WindowManager {
716    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
717        // Create the first window on app resume (or if all windows were closed on some platforms)
718        if self.windows.is_empty() {
719            self.create_window(event_loop);
720        }
721    }
722
723    fn window_event(
724        &mut self,
725        event_loop: &ActiveEventLoop,
726        window_id: WindowId,
727        event: WindowEvent,
728    ) {
729        // Route event to the appropriate window
730        let should_close = if let Some(window_state) = self.windows.get_mut(&window_id) {
731            window_state.handle_window_event(event_loop, event)
732        } else {
733            false
734        };
735
736        // Close window if requested
737        if should_close {
738            self.close_window(window_id);
739        }
740
741        // Exit if no windows remain
742        if self.should_exit {
743            event_loop.exit();
744        }
745    }
746
747    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
748        // Process menu events
749        // Find focused window (for now, use the first window if any)
750        let focused_window = self.windows.keys().next().copied();
751        self.process_menu_events(event_loop, focused_window);
752
753        // Process each window's about_to_wait logic
754        for window_state in self.windows.values_mut() {
755            window_state.about_to_wait(event_loop);
756        }
757
758        // Exit if no windows remain
759        if self.should_exit {
760            event_loop.exit();
761        }
762    }
763}