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