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 /// Update window title with shell integration info (cwd and exit code)
16 /// Only updates if not scrolled and not hovering over URL
17 pub(crate) fn update_window_title_with_shell_integration(&self) {
18 // Get active tab state
19 let tab = if let Some(t) = self.tab_manager.active_tab() {
20 t
21 } else {
22 return;
23 };
24
25 // Skip if scrolled (scrollback indicator takes priority)
26 if tab.scroll_state.offset != 0 {
27 return;
28 }
29
30 // Skip if hovering over URL (URL tooltip takes priority)
31 if tab.mouse.hovered_url.is_some() {
32 return;
33 }
34
35 // Skip if window not available
36 let window = if let Some(w) = &self.window {
37 w
38 } else {
39 return;
40 };
41
42 // Try to get shell integration info
43 if let Ok(term) = tab.terminal.try_lock() {
44 let mut title_parts = vec![self.config.window_title.clone()];
45
46 // Add window number if configured
47 if self.config.show_window_number {
48 title_parts.push(format!("[{}]", self.window_index));
49 }
50
51 // Add current working directory if available
52 if let Some(cwd) = term.shell_integration_cwd() {
53 // Abbreviate home directory to ~
54 let abbreviated_cwd = if let Some(home) = dirs::home_dir() {
55 cwd.replace(&home.to_string_lossy().to_string(), "~")
56 } else {
57 cwd
58 };
59 title_parts.push(format!("({})", abbreviated_cwd));
60 }
61
62 // Add running command indicator if a command is executing
63 if let Some(cmd_name) = term.get_running_command_name() {
64 title_parts.push(format!("[{}]", cmd_name));
65 }
66
67 // Add exit code indicator if last command failed
68 if let Some(exit_code) = term.shell_integration_exit_code()
69 && exit_code != 0
70 {
71 title_parts.push(format!("[Exit: {}]", exit_code));
72 }
73
74 // Add recording indicator
75 if self.is_recording {
76 title_parts.push("[RECORDING]".to_string());
77 }
78
79 // Build and set title
80 let title = title_parts.join(" ");
81 window.set_title(&title);
82 }
83 }
84
85 /// Sync shell integration data (exit code, command, cwd, hostname, username) to badge variables
86 pub(crate) fn sync_badge_shell_integration(&mut self) {
87 let tab = if let Some(t) = self.tab_manager.active_tab() {
88 t
89 } else {
90 return;
91 };
92
93 if let Ok(term) = tab.terminal.try_lock() {
94 let exit_code = term.shell_integration_exit_code();
95 let current_command = term.get_running_command_name();
96 let cwd = term.shell_integration_cwd();
97 let hostname = term.shell_integration_hostname();
98 let username = term.shell_integration_username();
99
100 let mut vars = self.badge_state.variables_mut();
101 let mut badge_changed = false;
102
103 if vars.exit_code != exit_code {
104 vars.set_exit_code(exit_code);
105 badge_changed = true;
106 }
107 if vars.current_command != current_command {
108 vars.set_current_command(current_command);
109 badge_changed = true;
110 }
111 if let Some(cwd) = cwd
112 && vars.path != cwd
113 {
114 vars.set_path(cwd);
115 badge_changed = true;
116 }
117 if let Some(ref host) = hostname
118 && vars.hostname != *host
119 {
120 vars.hostname = host.clone();
121 badge_changed = true;
122 } else if hostname.is_none() && !vars.hostname.is_empty() {
123 // Returned to localhost — keep the initial hostname from new()
124 }
125 if let Some(ref user) = username
126 && vars.username != *user
127 {
128 vars.username = user.clone();
129 badge_changed = true;
130 }
131 drop(vars);
132 if badge_changed {
133 self.badge_state.mark_dirty();
134 }
135 }
136 }
137
138 /// Handle window events for this window state
139 pub(crate) fn handle_window_event(
140 &mut self,
141 event_loop: &ActiveEventLoop,
142 event: WindowEvent,
143 ) -> bool {
144 use winit::keyboard::{Key, NamedKey};
145
146 // Debug: Log ALL keyboard events at entry to diagnose Space issue
147 if let WindowEvent::KeyboardInput {
148 event: key_event, ..
149 } = &event
150 {
151 match &key_event.logical_key {
152 Key::Character(s) => {
153 log::trace!(
154 "window_event: Character '{}', state={:?}",
155 s,
156 key_event.state
157 );
158 }
159 Key::Named(named) => {
160 log::trace!(
161 "window_event: Named key {:?}, state={:?}",
162 named,
163 key_event.state
164 );
165 }
166 other => {
167 log::trace!(
168 "window_event: Other key {:?}, state={:?}",
169 other,
170 key_event.state
171 );
172 }
173 }
174 }
175
176 // Let egui handle the event (needed for proper rendering state)
177 let (egui_consumed, egui_needs_repaint) =
178 if let (Some(egui_state), Some(window)) = (&mut self.egui_state, &self.window) {
179 let event_response = egui_state.on_window_event(window, &event);
180 // Request redraw if egui needs it (e.g., text input in modals)
181 if event_response.repaint {
182 window.request_redraw();
183 }
184 (event_response.consumed, event_response.repaint)
185 } else {
186 (false, false)
187 };
188 let _ = egui_needs_repaint; // Used above, silence unused warning
189
190 // Debug: Log when egui consumes events but we ignore it
191 // Note: Settings are handled by standalone SettingsWindow, not embedded UI
192 // Note: Profile drawer does NOT block input - only modal dialogs do
193 let any_ui_visible = self.help_ui.visible
194 || self.clipboard_history_ui.visible
195 || self.command_history_ui.visible
196 || self.shader_install_ui.visible
197 || self.integrations_ui.visible;
198 if egui_consumed
199 && !any_ui_visible
200 && let WindowEvent::KeyboardInput {
201 event: key_event, ..
202 } = &event
203 && let Key::Named(NamedKey::Space) = &key_event.logical_key
204 {
205 log::debug!("egui tried to consume Space (UI closed, ignoring)");
206 }
207
208 // When shader editor is visible, block keyboard events from terminal
209 // even if egui didn't consume them (egui might not have focus)
210 if any_ui_visible
211 && let WindowEvent::KeyboardInput {
212 event: key_event, ..
213 } = &event
214 // Always block keyboard input when UI is visible (except system keys)
215 && !matches!(
216 key_event.logical_key,
217 Key::Named(NamedKey::F1)
218 | Key::Named(NamedKey::F2)
219 | Key::Named(NamedKey::F3)
220 | Key::Named(NamedKey::F11)
221 | Key::Named(NamedKey::Escape)
222 )
223 {
224 return false;
225 }
226
227 if egui_consumed
228 && any_ui_visible
229 && !matches!(
230 event,
231 WindowEvent::CloseRequested | WindowEvent::RedrawRequested
232 )
233 {
234 return false; // Event consumed by egui, don't close window
235 }
236
237 match event {
238 WindowEvent::CloseRequested => {
239 log::info!("Close requested for window");
240
241 // Check if prompt_on_quit is enabled and there are active sessions
242 let tab_count = self.tab_manager.tab_count();
243 if self.config.prompt_on_quit
244 && tab_count > 0
245 && !self.quit_confirmation_ui.is_visible()
246 {
247 log::info!(
248 "Showing quit confirmation dialog ({} active sessions)",
249 tab_count
250 );
251 self.quit_confirmation_ui.show_confirmation(tab_count);
252 self.needs_redraw = true;
253 if let Some(window) = &self.window {
254 window.request_redraw();
255 }
256 return false; // Don't close yet - wait for user confirmation
257 }
258
259 self.perform_shutdown();
260 return true; // Signal to close this window
261 }
262
263 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
264 if let (Some(renderer), Some(window)) = (&mut self.renderer, &self.window) {
265 log::info!(
266 "Scale factor changed to {} (display change detected)",
267 scale_factor
268 );
269
270 let size = window.inner_size();
271 let (cols, rows) = renderer.handle_scale_factor_change(scale_factor, size);
272
273 // Reconfigure surface after scale factor change
274 // This is important when dragging between displays with different DPIs
275 renderer.reconfigure_surface();
276
277 // Calculate pixel dimensions
278 let cell_width = renderer.cell_width();
279 let cell_height = renderer.cell_height();
280 let width_px = (cols as f32 * cell_width) as usize;
281 let height_px = (rows as f32 * cell_height) as usize;
282
283 // Resize all tabs' terminals with pixel dimensions for TIOCGWINSZ support
284 for tab in self.tab_manager.tabs_mut() {
285 if let Ok(mut term) = tab.terminal.try_lock() {
286 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
287 }
288 }
289
290 // Reconfigure macOS Metal layer after display change
291 #[cfg(target_os = "macos")]
292 {
293 if let Err(e) =
294 crate::macos_metal::configure_metal_layer_for_performance(window)
295 {
296 log::warn!(
297 "Failed to reconfigure Metal layer after display change: {}",
298 e
299 );
300 }
301 }
302
303 // Request redraw to apply changes
304 window.request_redraw();
305 }
306 }
307
308 // Handle window moved - surface may become invalid when moving between monitors
309 WindowEvent::Moved(_) => {
310 if let (Some(renderer), Some(window)) = (&mut self.renderer, &self.window) {
311 log::debug!(
312 "Window moved - reconfiguring surface for potential display change"
313 );
314
315 // Reconfigure surface to handle potential display changes
316 // This catches cases where displays have same DPI but different surface properties
317 renderer.reconfigure_surface();
318
319 // On macOS, reconfigure the Metal layer for the potentially new display
320 #[cfg(target_os = "macos")]
321 {
322 if let Err(e) =
323 crate::macos_metal::configure_metal_layer_for_performance(window)
324 {
325 log::warn!(
326 "Failed to reconfigure Metal layer after window move: {}",
327 e
328 );
329 }
330 }
331
332 // Request redraw to ensure proper rendering on new display
333 window.request_redraw();
334 }
335 }
336
337 WindowEvent::Resized(physical_size) => {
338 if let Some(renderer) = &mut self.renderer {
339 let (cols, rows) = renderer.resize(physical_size);
340
341 // Calculate text area pixel dimensions
342 let cell_width = renderer.cell_width();
343 let cell_height = renderer.cell_height();
344 let width_px = (cols as f32 * cell_width) as usize;
345 let height_px = (rows as f32 * cell_height) as usize;
346
347 // Resize all tabs' terminals with pixel dimensions for TIOCGWINSZ support
348 // This allows applications like kitty icat to query pixel dimensions
349 // Note: The core library (v0.11.0+) implements scrollback reflow when
350 // width changes - wrapped lines are unwrapped/re-wrapped as needed.
351 for tab in self.tab_manager.tabs_mut() {
352 if let Ok(mut term) = tab.terminal.try_lock() {
353 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
354 tab.cache.scrollback_len = term.scrollback_len();
355 }
356 // Invalidate cell cache to force regeneration
357 tab.cache.cells = None;
358 }
359
360 // Update scrollbar for active tab
361 if let Some(tab) = self.tab_manager.active_tab() {
362 let total_lines = rows + tab.cache.scrollback_len;
363 let marks = if let Ok(term) = tab.terminal.try_lock() {
364 term.scrollback_marks()
365 } else {
366 Vec::new()
367 };
368 renderer.update_scrollbar(
369 tab.scroll_state.offset,
370 rows,
371 total_lines,
372 &marks,
373 );
374 }
375
376 // Update resize overlay state
377 self.resize_dimensions =
378 Some((physical_size.width, physical_size.height, cols, rows));
379 self.resize_overlay_visible = true;
380 // Hide overlay 1 second after resize stops
381 self.resize_overlay_hide_time =
382 Some(std::time::Instant::now() + std::time::Duration::from_secs(1));
383
384 // Notify tmux of the new size if gateway mode is active
385 self.notify_tmux_of_resize();
386 }
387 }
388
389 WindowEvent::KeyboardInput { event, .. } => {
390 self.handle_key_event(event, event_loop);
391 }
392
393 WindowEvent::ModifiersChanged(modifiers) => {
394 self.input_handler.update_modifiers(modifiers);
395 }
396
397 WindowEvent::MouseWheel { delta, .. } => {
398 // Skip terminal handling if egui UI is visible or using the pointer
399 // Note: any_ui_visible check is needed because is_egui_using_pointer()
400 // returns false before egui is initialized (e.g., at startup when
401 // shader_install_ui is shown before first render)
402 if !any_ui_visible && !self.is_egui_using_pointer() {
403 self.handle_mouse_wheel(delta);
404 }
405 }
406
407 WindowEvent::MouseInput { button, state, .. } => {
408 use winit::event::ElementState;
409
410 // Eat the first mouse press that brings the window into focus.
411 // Without this, the click is forwarded to the PTY where mouse-aware
412 // apps (tmux with `mouse on`) trigger a zero-char selection that
413 // clears the system clipboard — destroying any clipboard image.
414 if self.focus_click_pending && state == ElementState::Pressed {
415 self.focus_click_pending = false;
416 self.ui_consumed_mouse_press = true; // Also suppress the release
417 if let Some(window) = &self.window {
418 window.request_redraw();
419 }
420 } else {
421 // Track UI mouse consumption to prevent release events bleeding through
422 // when UI closes during a click (e.g., drawer toggle)
423 let ui_wants_pointer = any_ui_visible || self.is_egui_using_pointer();
424
425 if state == ElementState::Pressed {
426 if ui_wants_pointer {
427 self.ui_consumed_mouse_press = true;
428 if let Some(window) = &self.window {
429 window.request_redraw();
430 }
431 } else {
432 self.ui_consumed_mouse_press = false;
433 self.handle_mouse_button(button, state);
434 }
435 } else {
436 // Release: block if we consumed the press OR if UI wants pointer
437 if self.ui_consumed_mouse_press || ui_wants_pointer {
438 self.ui_consumed_mouse_press = false;
439 if let Some(window) = &self.window {
440 window.request_redraw();
441 }
442 } else {
443 self.handle_mouse_button(button, state);
444 }
445 }
446 }
447 }
448
449 WindowEvent::CursorMoved { position, .. } => {
450 // Skip terminal handling if egui UI is visible or using the pointer
451 if any_ui_visible || self.is_egui_using_pointer() {
452 // Request redraw so egui can update hover states
453 if let Some(window) = &self.window {
454 window.request_redraw();
455 }
456 } else {
457 self.handle_mouse_move((position.x, position.y));
458 }
459 }
460
461 WindowEvent::Focused(focused) => {
462 self.handle_focus_change(focused);
463 }
464
465 WindowEvent::RedrawRequested => {
466 // Skip rendering if shutting down
467 if self.is_shutting_down {
468 return false;
469 }
470
471 // Handle shell exit based on configured action
472 use crate::config::ShellExitAction;
473 use crate::pane::RestartState;
474
475 match self.config.shell_exit_action {
476 ShellExitAction::Keep => {
477 // Do nothing - keep dead shells showing
478 }
479
480 ShellExitAction::Close => {
481 // Original behavior: close exited panes and their tabs
482 let mut tabs_needing_resize: Vec<crate::tab::TabId> = Vec::new();
483
484 let tabs_to_close: Vec<crate::tab::TabId> = self
485 .tab_manager
486 .tabs_mut()
487 .iter_mut()
488 .filter_map(|tab| {
489 if tab.tmux_gateway_active || tab.tmux_pane_id.is_some() {
490 return None;
491 }
492 if tab.pane_manager.is_some() {
493 let (closed_panes, tab_should_close) = tab.close_exited_panes();
494 if !closed_panes.is_empty() {
495 log::info!(
496 "Tab {}: closed {} exited pane(s)",
497 tab.id,
498 closed_panes.len()
499 );
500 if !tab_should_close {
501 tabs_needing_resize.push(tab.id);
502 }
503 }
504 if tab_should_close {
505 return Some(tab.id);
506 }
507 }
508 None
509 })
510 .collect();
511
512 if !tabs_needing_resize.is_empty()
513 && let Some(renderer) = &self.renderer
514 {
515 let cell_width = renderer.cell_width();
516 let cell_height = renderer.cell_height();
517 let padding = self.config.pane_padding;
518 let title_offset = if self.config.show_pane_titles {
519 self.config.pane_title_height
520 } else {
521 0.0
522 };
523 for tab_id in tabs_needing_resize {
524 if let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
525 && let Some(pm) = tab.pane_manager_mut()
526 {
527 pm.resize_all_terminals_with_padding(
528 cell_width,
529 cell_height,
530 padding,
531 title_offset,
532 );
533 }
534 }
535 }
536
537 for tab_id in &tabs_to_close {
538 log::info!("Closing tab {} - all panes exited", tab_id);
539 if self.tab_manager.tab_count() <= 1 {
540 log::info!("Last tab, closing window");
541 self.is_shutting_down = true;
542 for tab in self.tab_manager.tabs_mut() {
543 tab.stop_refresh_task();
544 }
545 return true;
546 } else {
547 let _ = self.tab_manager.close_tab(*tab_id);
548 }
549 }
550
551 // Also check legacy single-pane tabs
552 let (shell_exited, active_tab_id, tab_count, tab_title, exit_notified) = {
553 if let Some(tab) = self.tab_manager.active_tab() {
554 let exited = tab.pane_manager.is_none()
555 && tab
556 .terminal
557 .try_lock()
558 .ok()
559 .is_some_and(|term| !term.is_running());
560 (
561 exited,
562 Some(tab.id),
563 self.tab_manager.tab_count(),
564 tab.title.clone(),
565 tab.exit_notified,
566 )
567 } else {
568 (false, None, 0, String::new(), false)
569 }
570 };
571
572 if shell_exited {
573 log::info!("Shell in active tab has exited");
574 if self.config.notification_session_ended && !exit_notified {
575 if let Some(tab) = self.tab_manager.active_tab_mut() {
576 tab.exit_notified = true;
577 }
578 let title = format!("Session Ended: {}", tab_title);
579 let message = "The shell process has exited".to_string();
580 self.deliver_notification(&title, &message);
581 }
582
583 if tab_count <= 1 {
584 log::info!("Last tab, closing window");
585 self.is_shutting_down = true;
586 for tab in self.tab_manager.tabs_mut() {
587 tab.stop_refresh_task();
588 }
589 return true;
590 } else if let Some(tab_id) = active_tab_id {
591 let _ = self.tab_manager.close_tab(tab_id);
592 }
593 }
594 }
595
596 ShellExitAction::RestartImmediately
597 | ShellExitAction::RestartWithPrompt
598 | ShellExitAction::RestartAfterDelay => {
599 // Handle restart variants
600 let config_clone = self.config.clone();
601
602 for tab in self.tab_manager.tabs_mut() {
603 if tab.tmux_gateway_active || tab.tmux_pane_id.is_some() {
604 continue;
605 }
606
607 if let Some(pm) = tab.pane_manager_mut() {
608 for pane in pm.all_panes_mut() {
609 let is_running = pane.is_running();
610
611 // Check if pane needs restart action
612 if !is_running && pane.restart_state.is_none() {
613 // Shell just exited, handle based on action
614 match self.config.shell_exit_action {
615 ShellExitAction::RestartImmediately => {
616 log::info!(
617 "Pane {} shell exited, restarting immediately",
618 pane.id
619 );
620 if let Err(e) = pane.respawn_shell(&config_clone) {
621 log::error!(
622 "Failed to respawn shell in pane {}: {}",
623 pane.id,
624 e
625 );
626 }
627 }
628 ShellExitAction::RestartWithPrompt => {
629 log::info!(
630 "Pane {} shell exited, showing restart prompt",
631 pane.id
632 );
633 pane.write_restart_prompt();
634 pane.restart_state =
635 Some(RestartState::AwaitingInput);
636 }
637 ShellExitAction::RestartAfterDelay => {
638 log::info!(
639 "Pane {} shell exited, will restart after 1s",
640 pane.id
641 );
642 pane.restart_state =
643 Some(RestartState::AwaitingDelay(
644 std::time::Instant::now(),
645 ));
646 }
647 _ => {}
648 }
649 }
650
651 // Check if waiting for delay and time has elapsed
652 if let Some(RestartState::AwaitingDelay(exit_time)) =
653 &pane.restart_state
654 && exit_time.elapsed() >= std::time::Duration::from_secs(1)
655 {
656 log::info!(
657 "Pane {} delay elapsed, restarting shell",
658 pane.id
659 );
660 if let Err(e) = pane.respawn_shell(&config_clone) {
661 log::error!(
662 "Failed to respawn shell in pane {}: {}",
663 pane.id,
664 e
665 );
666 }
667 }
668 }
669 }
670 }
671 }
672 }
673
674 self.render();
675 }
676
677 WindowEvent::DroppedFile(path) => {
678 self.handle_dropped_file(path);
679 }
680
681 WindowEvent::CursorEntered { .. } => {
682 // Focus follows mouse: auto-focus window when cursor enters
683 if self.config.focus_follows_mouse
684 && let Some(window) = &self.window
685 {
686 window.focus_window();
687 }
688 }
689
690 WindowEvent::ThemeChanged(system_theme) => {
691 let is_dark = system_theme == winit::window::Theme::Dark;
692 let theme_changed = self.config.apply_system_theme(is_dark);
693 let tab_style_changed = self.config.apply_system_tab_style(is_dark);
694
695 if theme_changed {
696 log::info!(
697 "System theme changed to {}, switching to theme: {}",
698 if is_dark { "dark" } else { "light" },
699 self.config.theme
700 );
701 let theme = self.config.load_theme();
702 for tab in self.tab_manager.tabs_mut() {
703 if let Ok(mut term) = tab.terminal.try_lock() {
704 term.set_theme(theme.clone());
705 }
706 tab.cache.cells = None;
707 }
708 }
709
710 if tab_style_changed {
711 log::info!(
712 "Auto tab style: switching to {} tab style",
713 if is_dark {
714 self.config.dark_tab_style.display_name()
715 } else {
716 self.config.light_tab_style.display_name()
717 }
718 );
719 }
720
721 if theme_changed || tab_style_changed {
722 if let Err(e) = self.config.save() {
723 log::error!("Failed to save config after theme change: {}", e);
724 }
725 self.needs_redraw = true;
726 self.request_redraw();
727 }
728 }
729
730 _ => {}
731 }
732
733 false // Don't close window
734 }
735
736 /// Handle window focus change for power saving
737 pub(crate) fn handle_focus_change(&mut self, focused: bool) {
738 if self.is_focused == focused {
739 return; // No change
740 }
741
742 self.is_focused = focused;
743
744 log::info!(
745 "Window focus changed: {}",
746 if focused { "focused" } else { "blurred" }
747 );
748
749 // Suppress the first mouse click after gaining focus to prevent it from
750 // being forwarded to the PTY. Without this, clicking to focus sends a
751 // mouse event to tmux (or other mouse-aware apps), which can trigger a
752 // zero-char selection that clears the system clipboard.
753 if focused {
754 self.focus_click_pending = true;
755 }
756
757 // Update renderer focus state for unfocused cursor styling
758 if let Some(renderer) = &mut self.renderer {
759 renderer.set_focused(focused);
760 }
761
762 // Handle shader animation pause/resume
763 if self.config.pause_shaders_on_blur
764 && let Some(renderer) = &mut self.renderer
765 {
766 if focused {
767 // Only resume if user has animation enabled in config
768 renderer.resume_shader_animations(
769 self.config.custom_shader_animation,
770 self.config.cursor_shader_animation,
771 );
772 } else {
773 renderer.pause_shader_animations();
774 }
775 }
776
777 // Re-assert tmux client size when window gains focus
778 // This ensures par-term's size is respected even after other clients resize tmux
779 if focused {
780 self.notify_tmux_of_resize();
781 }
782
783 // Forward focus events to all PTYs that have focus tracking enabled (DECSET 1004)
784 // This is needed for applications like tmux that rely on focus events
785 for tab in self.tab_manager.tabs_mut() {
786 if let Ok(term) = tab.terminal.try_lock() {
787 term.report_focus_change(focused);
788 }
789 // Also forward to all panes if split panes are active
790 if let Some(pm) = &tab.pane_manager {
791 for pane in pm.all_panes() {
792 if let Ok(term) = pane.terminal.try_lock() {
793 term.report_focus_change(focused);
794 }
795 }
796 }
797 }
798
799 // Handle refresh rate adjustment for all tabs
800 if self.config.pause_refresh_on_blur
801 && let Some(window) = &self.window
802 {
803 let fps = if focused {
804 self.config.max_fps
805 } else {
806 self.config.unfocused_fps
807 };
808 for tab in self.tab_manager.tabs_mut() {
809 tab.stop_refresh_task();
810 tab.start_refresh_task(Arc::clone(&self.runtime), Arc::clone(window), fps);
811 }
812 log::info!(
813 "Adjusted refresh rate to {} FPS ({})",
814 fps,
815 if focused { "focused" } else { "unfocused" }
816 );
817 }
818
819 // Request a redraw when focus changes
820 self.needs_redraw = true;
821 self.request_redraw();
822 }
823
824 /// Process per-window updates in about_to_wait
825 pub(crate) fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
826 // Skip all processing if shutting down
827 if self.is_shutting_down {
828 return;
829 }
830
831 // Check for and deliver notifications (OSC 9/777)
832 self.check_notifications();
833
834 // Check for file transfer events (downloads, uploads, progress)
835 self.check_file_transfers();
836
837 // Check for bell events and play audio/visual feedback
838 self.check_bell();
839
840 // Check for trigger action results and dispatch them
841 self.check_trigger_actions();
842
843 // Check for activity/idle notifications
844 self.check_activity_idle_notifications();
845
846 // Check for session exit notifications
847 self.check_session_exit_notifications();
848
849 // Check for shader hot reload events
850 if self.check_shader_reload() {
851 log::debug!("Shader hot reload triggered redraw");
852 }
853
854 // Check for config file changes (e.g., from ACP agent)
855 self.check_config_reload();
856
857 // Check for MCP server config updates (.config-update.json)
858 self.check_config_update_file();
859
860 // Check for tmux control mode notifications
861 if self.check_tmux_notifications() {
862 self.needs_redraw = true;
863 }
864
865 // Update window title with shell integration info (CWD, exit code)
866 self.update_window_title_with_shell_integration();
867
868 // Sync shell integration data to badge variables
869 self.sync_badge_shell_integration();
870
871 // Check for automatic profile switching based on hostname detection (OSC 7)
872 if self.check_auto_profile_switch() {
873 self.needs_redraw = true;
874 }
875
876 // --- POWER SAVING & SMART REDRAW LOGIC ---
877 // We use ControlFlow::WaitUntil to sleep until the next expected event.
878 // This drastically reduces CPU/GPU usage compared to continuous polling (ControlFlow::Poll).
879 // The loop calculates the earliest time any component needs to update.
880
881 let now = std::time::Instant::now();
882 let mut next_wake = now + std::time::Duration::from_secs(1); // Default sleep for 1s of inactivity
883
884 // Calculate frame interval based on focus state for power saving
885 // When pause_refresh_on_blur is enabled and window is unfocused, use slower refresh rate
886 let frame_interval_ms = if self.config.pause_refresh_on_blur && !self.is_focused {
887 // Use unfocused FPS (e.g., 10 FPS = 100ms interval)
888 1000 / self.config.unfocused_fps.max(1)
889 } else {
890 // Use normal animation rate based on max_fps
891 1000 / self.config.max_fps.max(1)
892 };
893 let frame_interval = std::time::Duration::from_millis(frame_interval_ms as u64);
894
895 // Check if enough time has passed since last render for FPS throttling
896 let time_since_last_render = self
897 .last_render_time
898 .map(|t| now.duration_since(t))
899 .unwrap_or(frame_interval); // If no last render, allow immediate render
900 let can_render = time_since_last_render >= frame_interval;
901
902 // --- FLICKER REDUCTION LOGIC ---
903 // When reduce_flicker is enabled and cursor is hidden, delay rendering
904 // to batch updates and reduce visual flicker during bulk terminal operations.
905 let should_delay_for_flicker = if self.config.reduce_flicker {
906 let cursor_hidden = if let Some(tab) = self.tab_manager.active_tab() {
907 if let Ok(term) = tab.terminal.try_lock() {
908 !term.is_cursor_visible() && !self.config.lock_cursor_visibility
909 } else {
910 false
911 }
912 } else {
913 false
914 };
915
916 if cursor_hidden {
917 // Track when cursor was first hidden
918 if self.cursor_hidden_since.is_none() {
919 self.cursor_hidden_since = Some(now);
920 }
921
922 // Check bypass conditions
923 let delay_expired = self
924 .cursor_hidden_since
925 .map(|t| {
926 now.duration_since(t)
927 >= std::time::Duration::from_millis(
928 self.config.reduce_flicker_delay_ms as u64,
929 )
930 })
931 .unwrap_or(false);
932
933 // Bypass for UI interactions
934 let any_ui_visible = self.help_ui.visible
935 || self.clipboard_history_ui.visible
936 || self.command_history_ui.visible
937 || self.search_ui.visible
938 || self.shader_install_ui.visible
939 || self.integrations_ui.visible
940 || self.resize_overlay_visible;
941
942 // Delay unless bypass conditions met
943 !delay_expired && !any_ui_visible
944 } else {
945 // Cursor visible - clear tracking and allow render
946 if self.cursor_hidden_since.is_some() {
947 self.cursor_hidden_since = None;
948 self.flicker_pending_render = false;
949 self.needs_redraw = true; // Render accumulated updates
950 }
951 false
952 }
953 } else {
954 false
955 };
956
957 // Schedule wake at delay expiry if delaying
958 if should_delay_for_flicker {
959 self.flicker_pending_render = true;
960 if let Some(hidden_since) = self.cursor_hidden_since {
961 let delay =
962 std::time::Duration::from_millis(self.config.reduce_flicker_delay_ms as u64);
963 let render_time = hidden_since + delay;
964 if render_time < next_wake {
965 next_wake = render_time;
966 }
967 }
968 } else if self.flicker_pending_render {
969 // Delay ended - trigger accumulated render
970 self.flicker_pending_render = false;
971 if can_render {
972 self.needs_redraw = true;
973 }
974 }
975
976 // --- THROUGHPUT MODE LOGIC ---
977 // When maximize_throughput is enabled, always batch renders regardless of cursor state.
978 // Uses a longer interval than flicker reduction for better throughput during bulk output.
979 let should_delay_for_throughput = if self.config.maximize_throughput {
980 // Initialize batch start time if not set
981 if self.throughput_batch_start.is_none() {
982 self.throughput_batch_start = Some(now);
983 }
984
985 let interval =
986 std::time::Duration::from_millis(self.config.throughput_render_interval_ms as u64);
987 let batch_start = self.throughput_batch_start.unwrap();
988
989 // Check if interval has elapsed
990 if now.duration_since(batch_start) >= interval {
991 self.throughput_batch_start = Some(now); // Reset for next batch
992 false // Allow render
993 } else {
994 true // Delay render
995 }
996 } else {
997 // Clear tracking when disabled
998 if self.throughput_batch_start.is_some() {
999 self.throughput_batch_start = None;
1000 }
1001 false
1002 };
1003
1004 // Schedule wake for throughput mode
1005 if should_delay_for_throughput && let Some(batch_start) = self.throughput_batch_start {
1006 let interval =
1007 std::time::Duration::from_millis(self.config.throughput_render_interval_ms as u64);
1008 let render_time = batch_start + interval;
1009 if render_time < next_wake {
1010 next_wake = render_time;
1011 }
1012 }
1013
1014 // Combine delays: throughput mode OR flicker delay
1015 let should_delay_render = should_delay_for_throughput || should_delay_for_flicker;
1016
1017 // 1. Cursor Blinking
1018 // Wake up exactly when the cursor needs to toggle visibility or fade.
1019 // Skip cursor blinking when unfocused with pause_refresh_on_blur to save power.
1020 if self.config.cursor_blink && (self.is_focused || !self.config.pause_refresh_on_blur) {
1021 if self.cursor_blink_timer.is_none() {
1022 let blink_interval =
1023 std::time::Duration::from_millis(self.config.cursor_blink_interval);
1024 self.cursor_blink_timer = Some(now + blink_interval);
1025 }
1026
1027 if let Some(next_blink) = self.cursor_blink_timer {
1028 if now >= next_blink {
1029 // Time to toggle: trigger redraw (if throttle allows) and schedule next phase
1030 if can_render {
1031 self.needs_redraw = true;
1032 }
1033 let blink_interval =
1034 std::time::Duration::from_millis(self.config.cursor_blink_interval);
1035 self.cursor_blink_timer = Some(now + blink_interval);
1036 } else if next_blink < next_wake {
1037 // Schedule wake-up for the next toggle
1038 next_wake = next_blink;
1039 }
1040 }
1041 }
1042
1043 // 2. Smooth Scrolling & Animations
1044 // If a scroll interpolation or terminal animation is active, use calculated frame interval.
1045 if let Some(tab) = self.tab_manager.active_tab() {
1046 if tab.scroll_state.animation_start.is_some() {
1047 if can_render {
1048 self.needs_redraw = true;
1049 }
1050 let next_frame = now + frame_interval;
1051 if next_frame < next_wake {
1052 next_wake = next_frame;
1053 }
1054 }
1055
1056 // 3. Visual Bell Feedback
1057 // Maintain frame rate during the visual flash fade-out.
1058 if tab.bell.visual_flash.is_some() {
1059 if can_render {
1060 self.needs_redraw = true;
1061 }
1062 let next_frame = now + frame_interval;
1063 if next_frame < next_wake {
1064 next_wake = next_frame;
1065 }
1066 }
1067
1068 // 4. Interactive UI Elements
1069 // Ensure high responsiveness during mouse dragging (text selection or scrollbar).
1070 // Always allow these for responsiveness, even if throttled.
1071 if (tab.mouse.is_selecting
1072 || tab.mouse.selection.is_some()
1073 || tab.scroll_state.dragging)
1074 && tab.mouse.button_pressed
1075 {
1076 self.needs_redraw = true;
1077 }
1078 }
1079
1080 // 5. Resize Overlay
1081 // Check if the resize overlay should be hidden (timer expired).
1082 if self.resize_overlay_visible
1083 && let Some(hide_time) = self.resize_overlay_hide_time
1084 {
1085 if now >= hide_time {
1086 // Hide the overlay
1087 self.resize_overlay_visible = false;
1088 self.resize_overlay_hide_time = None;
1089 self.needs_redraw = true;
1090 } else {
1091 // Overlay still visible - request redraw and schedule wake
1092 if can_render {
1093 self.needs_redraw = true;
1094 }
1095 if hide_time < next_wake {
1096 next_wake = hide_time;
1097 }
1098 }
1099 }
1100
1101 // 5b. Toast Notification
1102 // Check if the toast notification should be hidden (timer expired).
1103 if self.toast_message.is_some()
1104 && let Some(hide_time) = self.toast_hide_time
1105 {
1106 if now >= hide_time {
1107 // Hide the toast
1108 self.toast_message = None;
1109 self.toast_hide_time = None;
1110 self.needs_redraw = true;
1111 } else {
1112 // Toast still visible - request redraw and schedule wake
1113 if can_render {
1114 self.needs_redraw = true;
1115 }
1116 if hide_time < next_wake {
1117 next_wake = hide_time;
1118 }
1119 }
1120 }
1121
1122 // 5c. Pane Identification Overlay
1123 // Check if the pane index overlay should be hidden (timer expired).
1124 if let Some(hide_time) = self.pane_identify_hide_time {
1125 if now >= hide_time {
1126 self.pane_identify_hide_time = None;
1127 self.needs_redraw = true;
1128 } else {
1129 if can_render {
1130 self.needs_redraw = true;
1131 }
1132 if hide_time < next_wake {
1133 next_wake = hide_time;
1134 }
1135 }
1136 }
1137
1138 // 5b. Session undo expiry: prune closed tab metadata that has timed out
1139 if !self.closed_tabs.is_empty() && self.config.session_undo_timeout_secs > 0 {
1140 let timeout =
1141 std::time::Duration::from_secs(self.config.session_undo_timeout_secs as u64);
1142 self.closed_tabs
1143 .retain(|info| now.duration_since(info.closed_at) < timeout);
1144 }
1145
1146 // 6. Custom Background Shaders
1147 // If a custom shader is animated, render at the calculated frame interval.
1148 // When unfocused with pause_refresh_on_blur, this uses the slower unfocused_fps rate.
1149 if let Some(renderer) = &self.renderer
1150 && renderer.needs_continuous_render()
1151 {
1152 if can_render {
1153 self.needs_redraw = true;
1154 }
1155 // Schedule next frame at the appropriate interval
1156 let next_frame = self
1157 .last_render_time
1158 .map(|t| t + frame_interval)
1159 .unwrap_or(now);
1160 // Ensure we don't schedule in the past
1161 let next_frame = if next_frame <= now {
1162 now + frame_interval
1163 } else {
1164 next_frame
1165 };
1166 if next_frame < next_wake {
1167 next_wake = next_frame;
1168 }
1169 }
1170
1171 // 7. Shader Install Dialog
1172 // Force continuous redraws when shader install dialog is visible (for spinner animation)
1173 // and when installation is in progress (to check for completion)
1174 if self.shader_install_ui.visible {
1175 self.needs_redraw = true;
1176 // Schedule frequent redraws for smooth spinner animation
1177 let next_frame = now + std::time::Duration::from_millis(16); // ~60fps
1178 if next_frame < next_wake {
1179 next_wake = next_frame;
1180 }
1181 }
1182
1183 // 8. Anti-idle Keep-alive
1184 // Periodically send keep-alive codes to prevent SSH/connection timeouts.
1185 if let Some(next_anti_idle) = self.handle_anti_idle(now)
1186 && next_anti_idle < next_wake
1187 {
1188 next_wake = next_anti_idle;
1189 }
1190
1191 // --- TRIGGER REDRAW ---
1192 // Request a redraw if any of the logic above determined an update is due.
1193 // Respect combined delay (throughput mode OR flicker reduction).
1194 if self.needs_redraw
1195 && !should_delay_render
1196 && let Some(window) = &self.window
1197 {
1198 window.request_redraw();
1199 self.needs_redraw = false;
1200 }
1201
1202 // Set the calculated sleep interval
1203 event_loop.set_control_flow(ControlFlow::WaitUntil(next_wake));
1204 }
1205}
1206
1207impl ApplicationHandler for WindowManager {
1208 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1209 // Create the first window on app resume (or if all windows were closed on some platforms)
1210 if self.windows.is_empty() {
1211 if !self.auto_restore_done {
1212 self.auto_restore_done = true;
1213
1214 // Session restore takes precedence when enabled
1215 if self.config.restore_session && self.restore_session(event_loop) {
1216 return;
1217 }
1218
1219 // Try auto-restore arrangement if configured
1220 if let Some(ref name) = self.config.auto_restore_arrangement.clone()
1221 && !name.is_empty()
1222 && self.arrangement_manager.find_by_name(name).is_some()
1223 {
1224 log::info!("Auto-restoring arrangement: {}", name);
1225 self.restore_arrangement_by_name(name, event_loop);
1226 return;
1227 }
1228 }
1229 self.create_window(event_loop);
1230 }
1231 }
1232
1233 fn window_event(
1234 &mut self,
1235 event_loop: &ActiveEventLoop,
1236 window_id: WindowId,
1237 event: WindowEvent,
1238 ) {
1239 // Check if this event is for the settings window
1240 if self.is_settings_window(window_id) {
1241 if let Some(action) = self.handle_settings_window_event(event) {
1242 use crate::settings_window::SettingsWindowAction;
1243 match action {
1244 SettingsWindowAction::Close => {
1245 // Already handled in handle_settings_window_event
1246 }
1247 SettingsWindowAction::ApplyConfig(config) => {
1248 // Apply live config changes to all terminal windows
1249 log::info!("SETTINGS: ApplyConfig shader={:?}", config.custom_shader);
1250 self.apply_config_to_windows(&config);
1251 }
1252 SettingsWindowAction::SaveConfig(config) => {
1253 // Save config to disk and apply to all windows
1254 if let Err(e) = config.save() {
1255 log::error!("Failed to save config: {}", e);
1256 } else {
1257 log::info!("Configuration saved successfully");
1258 }
1259 self.apply_config_to_windows(&config);
1260 // Update settings window with saved config
1261 if let Some(settings_window) = &mut self.settings_window {
1262 settings_window.update_config(config);
1263 }
1264 }
1265 SettingsWindowAction::ApplyShader(shader_result) => {
1266 let _ = self.apply_shader_from_editor(&shader_result.source);
1267 }
1268 SettingsWindowAction::ApplyCursorShader(cursor_shader_result) => {
1269 let _ = self.apply_cursor_shader_from_editor(&cursor_shader_result.source);
1270 }
1271 SettingsWindowAction::TestNotification => {
1272 // Send a test notification to verify permissions
1273 self.send_test_notification();
1274 }
1275 SettingsWindowAction::SaveProfiles(profiles) => {
1276 // Apply saved profiles to all terminal windows
1277 for window_state in self.windows.values_mut() {
1278 window_state.apply_profile_changes(profiles.clone());
1279 }
1280 // Update the profiles menu
1281 if let Some(menu) = &mut self.menu {
1282 let profile_refs: Vec<&crate::profile::Profile> =
1283 profiles.iter().collect();
1284 menu.update_profiles(&profile_refs);
1285 }
1286 }
1287 SettingsWindowAction::OpenProfile(id) => {
1288 // Open profile in the focused terminal window
1289 if let Some(window_id) = self.get_focused_window_id()
1290 && let Some(window_state) = self.windows.get_mut(&window_id)
1291 {
1292 window_state.open_profile(id);
1293 }
1294 }
1295 SettingsWindowAction::StartCoprocess(index) => {
1296 log::debug!("Handler: received StartCoprocess({})", index);
1297 self.start_coprocess(index);
1298 }
1299 SettingsWindowAction::StopCoprocess(index) => {
1300 log::debug!("Handler: received StopCoprocess({})", index);
1301 self.stop_coprocess(index);
1302 }
1303 SettingsWindowAction::StartScript(index) => {
1304 crate::debug_info!("SCRIPT", "Handler: received StartScript({})", index);
1305 self.start_script(index);
1306 }
1307 SettingsWindowAction::StopScript(index) => {
1308 log::debug!("Handler: received StopScript({})", index);
1309 self.stop_script(index);
1310 }
1311 SettingsWindowAction::OpenLogFile => {
1312 let log_path = crate::debug::log_path();
1313 log::info!("Opening log file: {}", log_path.display());
1314 if let Err(e) = open::that(&log_path) {
1315 log::error!("Failed to open log file: {}", e);
1316 }
1317 }
1318 SettingsWindowAction::SaveArrangement(name) => {
1319 self.save_arrangement(name, event_loop);
1320 }
1321 SettingsWindowAction::RestoreArrangement(id) => {
1322 self.restore_arrangement(id, event_loop);
1323 }
1324 SettingsWindowAction::DeleteArrangement(id) => {
1325 self.delete_arrangement(id);
1326 }
1327 SettingsWindowAction::RenameArrangement(id, new_name) => {
1328 // Special sentinel values for reorder operations
1329 if new_name == "__move_up__" {
1330 self.move_arrangement_up(id);
1331 } else if new_name == "__move_down__" {
1332 self.move_arrangement_down(id);
1333 } else {
1334 self.rename_arrangement(id, new_name);
1335 }
1336 }
1337 SettingsWindowAction::ForceUpdateCheck => {
1338 self.force_update_check_for_settings();
1339 }
1340 SettingsWindowAction::InstallUpdate(_version) => {
1341 // The update is handled asynchronously inside SettingsUI.
1342 // The InstallUpdate action is emitted for logging purposes.
1343 log::info!("Self-update initiated from settings UI");
1344 }
1345 SettingsWindowAction::IdentifyPanes => {
1346 // Flash pane index overlays on all terminal windows
1347 for window_state in self.windows.values_mut() {
1348 window_state.show_pane_indices(std::time::Duration::from_secs(3));
1349 }
1350 }
1351 SettingsWindowAction::InstallShellIntegration => {
1352 match crate::shell_integration_installer::install(None) {
1353 Ok(result) => {
1354 log::info!(
1355 "Shell integration installed for {:?} at {:?}",
1356 result.shell,
1357 result.script_path
1358 );
1359 if let Some(sw) = &mut self.settings_window {
1360 sw.request_redraw();
1361 }
1362 }
1363 Err(e) => {
1364 log::error!("Failed to install shell integration: {}", e);
1365 }
1366 }
1367 }
1368 SettingsWindowAction::UninstallShellIntegration => {
1369 match crate::shell_integration_installer::uninstall() {
1370 Ok(_) => {
1371 log::info!("Shell integration uninstalled");
1372 if let Some(sw) = &mut self.settings_window {
1373 sw.request_redraw();
1374 }
1375 }
1376 Err(e) => {
1377 log::error!("Failed to uninstall shell integration: {}", e);
1378 }
1379 }
1380 }
1381 SettingsWindowAction::None => {}
1382 }
1383 }
1384 return;
1385 }
1386
1387 // Check if this is a resize event (before the event is consumed)
1388 let is_resize = matches!(event, WindowEvent::Resized(_));
1389
1390 // Route event to the appropriate terminal window
1391 let (should_close, shader_states, grid_size) =
1392 if let Some(window_state) = self.windows.get_mut(&window_id) {
1393 let close = window_state.handle_window_event(event_loop, event);
1394 // Capture shader states to sync to settings window
1395 let states = (
1396 window_state.config.custom_shader_enabled,
1397 window_state.config.cursor_shader_enabled,
1398 );
1399 // Capture grid size if this was a resize
1400 let size = if is_resize {
1401 window_state.renderer.as_ref().map(|r| r.grid_size())
1402 } else {
1403 None
1404 };
1405 (close, Some(states), size)
1406 } else {
1407 (false, None, None)
1408 };
1409
1410 // Sync shader states to settings window to prevent it from overwriting keybinding toggles
1411 if let (Some(settings_window), Some((custom_enabled, cursor_enabled))) =
1412 (&mut self.settings_window, shader_states)
1413 {
1414 settings_window.sync_shader_states(custom_enabled, cursor_enabled);
1415 }
1416
1417 // Update settings window with new terminal dimensions after resize
1418 if let (Some(settings_window), Some((cols, rows))) = (&mut self.settings_window, grid_size)
1419 {
1420 settings_window.settings_ui.update_current_size(cols, rows);
1421 }
1422
1423 // Close window if requested
1424 if should_close {
1425 self.close_window(window_id);
1426 }
1427
1428 // Exit if no windows remain
1429 if self.should_exit {
1430 event_loop.exit();
1431 }
1432 }
1433
1434 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
1435 // Check CLI timing-based options (exit-after, screenshot, command)
1436 self.check_cli_timers();
1437
1438 // Check for updates (respects configured frequency)
1439 self.check_for_updates();
1440
1441 // Process menu events
1442 // Find the actually focused window (the one with is_focused == true)
1443 let focused_window = self.get_focused_window_id();
1444 self.process_menu_events(event_loop, focused_window);
1445
1446 // Check if any window requested opening the settings window
1447 // Also collect shader reload results for propagation to standalone settings window
1448 let mut open_settings = false;
1449 let mut open_settings_profiles_tab = false;
1450 let mut background_shader_result: Option<Option<String>> = None;
1451 let mut cursor_shader_result: Option<Option<String>> = None;
1452 let mut profiles_to_update: Option<Vec<crate::profile::Profile>> = None;
1453 let mut arrangement_restore_name: Option<String> = None;
1454 let mut reload_dynamic_profiles = false;
1455 let mut config_changed_by_agent = false;
1456
1457 for window_state in self.windows.values_mut() {
1458 if window_state.open_settings_window_requested {
1459 window_state.open_settings_window_requested = false;
1460 open_settings = true;
1461 }
1462 if window_state.open_settings_profiles_tab {
1463 window_state.open_settings_profiles_tab = false;
1464 open_settings_profiles_tab = true;
1465 }
1466
1467 // Check for arrangement restore request from keybinding
1468 if let Some(name) = window_state.pending_arrangement_restore.take() {
1469 arrangement_restore_name = Some(name);
1470 }
1471
1472 // Check for dynamic profile reload request from keybinding
1473 if window_state.reload_dynamic_profiles_requested {
1474 window_state.reload_dynamic_profiles_requested = false;
1475 reload_dynamic_profiles = true;
1476 }
1477
1478 // Check if profiles menu needs updating (from profile modal save)
1479 if window_state.profiles_menu_needs_update {
1480 window_state.profiles_menu_needs_update = false;
1481 // Get a copy of the profiles for menu update
1482 profiles_to_update = Some(window_state.profile_manager.to_vec());
1483 }
1484
1485 window_state.about_to_wait(event_loop);
1486
1487 // If an agent/MCP config update was applied, sync to WindowManager's
1488 // config so that subsequent saves (update checker, settings) don't
1489 // overwrite the agent's changes.
1490 if window_state.config_changed_by_agent {
1491 window_state.config_changed_by_agent = false;
1492 config_changed_by_agent = true;
1493 }
1494
1495 // Collect shader reload results and clear them from window_state
1496 if let Some(result) = window_state.background_shader_reload_result.take() {
1497 background_shader_result = Some(result);
1498 }
1499 if let Some(result) = window_state.cursor_shader_reload_result.take() {
1500 cursor_shader_result = Some(result);
1501 }
1502 }
1503
1504 // Sync agent config changes to WindowManager and settings window
1505 // so other saves (update checker, settings) don't overwrite the agent's changes
1506 if config_changed_by_agent && let Some(window_state) = self.windows.values().next() {
1507 log::info!("CONFIG: syncing agent config changes to WindowManager");
1508 self.config = window_state.config.clone();
1509 // Force-update the settings window's config copy so it doesn't
1510 // send stale values back via ApplyConfig/SaveConfig.
1511 // Must use force_update_config to bypass the has_changes guard.
1512 if let Some(settings_window) = &mut self.settings_window {
1513 settings_window.force_update_config(self.config.clone());
1514 }
1515 }
1516
1517 // Check for dynamic profile updates
1518 while let Some(update) = self.dynamic_profile_manager.try_recv() {
1519 self.dynamic_profile_manager.update_status(&update);
1520
1521 // Merge into all window profile managers
1522 for window_state in self.windows.values_mut() {
1523 crate::profile::dynamic::merge_dynamic_profiles(
1524 &mut window_state.profile_manager,
1525 &update.profiles,
1526 &update.url,
1527 &update.conflict_resolution,
1528 );
1529 window_state.profiles_menu_needs_update = true;
1530 }
1531
1532 log::info!(
1533 "Dynamic profiles updated from {}: {} profiles{}",
1534 update.url,
1535 update.profiles.len(),
1536 update
1537 .error
1538 .as_ref()
1539 .map_or(String::new(), |e| format!(" (error: {e})"))
1540 );
1541
1542 // Ensure profiles_to_update is refreshed after dynamic merge
1543 if let Some(window_state) = self.windows.values().next() {
1544 profiles_to_update = Some(window_state.profile_manager.to_vec());
1545 }
1546 }
1547
1548 // Trigger dynamic profile refresh if requested via keybinding
1549 if reload_dynamic_profiles {
1550 self.dynamic_profile_manager
1551 .refresh_all(&self.config.dynamic_profile_sources, &self.runtime);
1552 }
1553
1554 // Update profiles menu if profiles changed
1555 if let Some(profiles) = profiles_to_update
1556 && let Some(menu) = &mut self.menu
1557 {
1558 let profile_refs: Vec<&crate::profile::Profile> = profiles.iter().collect();
1559 menu.update_profiles(&profile_refs);
1560 }
1561
1562 // Open settings window if requested (F12 or Cmd+,)
1563 if open_settings {
1564 self.open_settings_window(event_loop);
1565 }
1566
1567 // Navigate to Profiles tab if requested (from drawer "Manage" button)
1568 if open_settings_profiles_tab && let Some(sw) = &mut self.settings_window {
1569 sw.settings_ui
1570 .set_selected_tab(crate::settings_ui::sidebar::SettingsTab::Profiles);
1571 }
1572
1573 // Restore arrangement if requested via keybinding
1574 if let Some(name) = arrangement_restore_name {
1575 self.restore_arrangement_by_name(&name, event_loop);
1576 }
1577
1578 // Propagate shader reload results to standalone settings window
1579 if let Some(settings_window) = &mut self.settings_window {
1580 if let Some(result) = background_shader_result {
1581 match result {
1582 Some(err) => settings_window.set_shader_error(Some(err)),
1583 None => settings_window.clear_shader_error(),
1584 }
1585 }
1586 if let Some(result) = cursor_shader_result {
1587 match result {
1588 Some(err) => settings_window.set_cursor_shader_error(Some(err)),
1589 None => settings_window.clear_cursor_shader_error(),
1590 }
1591 }
1592 }
1593
1594 // Close any windows that have is_shutting_down set
1595 // This handles deferred closes from quit confirmation, tab bar close, and shell exit
1596 let shutting_down: Vec<_> = self
1597 .windows
1598 .iter()
1599 .filter(|(_, ws)| ws.is_shutting_down)
1600 .map(|(id, _)| *id)
1601 .collect();
1602
1603 for window_id in shutting_down {
1604 self.close_window(window_id);
1605 }
1606
1607 // Sync coprocess and script running state to settings window
1608 if self.settings_window.is_some() {
1609 self.sync_coprocess_running_state();
1610 self.sync_script_running_state();
1611 }
1612
1613 // Request redraw for settings window if it needs continuous updates
1614 self.request_settings_redraw();
1615
1616 // Exit if no windows remain
1617 if self.should_exit {
1618 event_loop.exit();
1619 }
1620 }
1621}