Skip to main content

par_term/app/
window_manager.rs

1//! Multi-window manager for the terminal emulator
2//!
3//! This module contains `WindowManager`, which coordinates multiple terminal windows,
4//! handles the native menu system, and manages shared resources.
5
6use crate::app::window_state::WindowState;
7use crate::arrangements::{self, ArrangementId, ArrangementManager};
8use crate::cli::RuntimeOptions;
9use crate::config::{Config, resolve_shader_config};
10use crate::menu::{MenuAction, MenuManager};
11use crate::settings_window::{SettingsWindow, SettingsWindowAction};
12use crate::update_checker::{UpdateCheckResult, UpdateChecker};
13
14/// Convert a main-crate UpdateCheckResult to the settings-ui crate's type.
15fn to_settings_update_result(result: &UpdateCheckResult) -> crate::settings_ui::UpdateCheckResult {
16    match result {
17        UpdateCheckResult::UpToDate => crate::settings_ui::UpdateCheckResult::UpToDate,
18        UpdateCheckResult::UpdateAvailable(info) => {
19            crate::settings_ui::UpdateCheckResult::UpdateAvailable(
20                crate::settings_ui::UpdateCheckInfo {
21                    version: info.version.clone(),
22                    release_notes: info.release_notes.clone(),
23                    release_url: info.release_url.clone(),
24                    published_at: info.published_at.clone(),
25                },
26            )
27        }
28        UpdateCheckResult::Disabled => crate::settings_ui::UpdateCheckResult::Disabled,
29        UpdateCheckResult::Skipped => crate::settings_ui::UpdateCheckResult::Skipped,
30        UpdateCheckResult::Error(e) => crate::settings_ui::UpdateCheckResult::Error(e.clone()),
31    }
32}
33use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::time::Instant;
37use tokio::runtime::Runtime;
38use winit::event::WindowEvent;
39use winit::event_loop::ActiveEventLoop;
40use winit::window::WindowId;
41
42/// Manages multiple terminal windows and shared resources
43pub struct WindowManager {
44    /// Per-window state indexed by window ID
45    pub(crate) windows: HashMap<WindowId, WindowState>,
46    /// Native menu manager
47    pub(crate) menu: Option<MenuManager>,
48    /// Shared configuration (read at startup, each window gets a clone)
49    pub(crate) config: Config,
50    /// Shared async runtime
51    pub(crate) runtime: Arc<Runtime>,
52    /// Flag to indicate if app should exit
53    pub(crate) should_exit: bool,
54    /// Counter for generating unique window IDs during creation
55    pending_window_count: usize,
56    /// Separate settings window (if open)
57    pub(crate) settings_window: Option<SettingsWindow>,
58    /// Runtime options from CLI
59    pub(crate) runtime_options: RuntimeOptions,
60    /// When the app started (for timing-based CLI options)
61    pub(crate) start_time: Option<Instant>,
62    /// Whether the command has been sent
63    pub(crate) command_sent: bool,
64    /// Whether the screenshot has been taken
65    pub(crate) screenshot_taken: bool,
66    /// Update checker for checking GitHub releases
67    pub(crate) update_checker: UpdateChecker,
68    /// Time of next scheduled update check
69    pub(crate) next_update_check: Option<Instant>,
70    /// Last update check result (for display in settings)
71    pub(crate) last_update_result: Option<UpdateCheckResult>,
72    /// Saved window arrangement manager
73    pub(crate) arrangement_manager: ArrangementManager,
74    /// Whether auto-restore has been attempted this session
75    pub(crate) auto_restore_done: bool,
76    /// Dynamic profile manager for fetching remote profiles
77    pub(crate) dynamic_profile_manager: crate::profile::DynamicProfileManager,
78}
79
80impl WindowManager {
81    /// Create a new window manager
82    pub fn new(config: Config, runtime: Arc<Runtime>, runtime_options: RuntimeOptions) -> Self {
83        // Load saved arrangements
84        let arrangement_manager = match arrangements::storage::load_arrangements() {
85            Ok(manager) => manager,
86            Err(e) => {
87                log::warn!("Failed to load arrangements: {}", e);
88                ArrangementManager::new()
89            }
90        };
91
92        let mut dynamic_profile_manager = crate::profile::DynamicProfileManager::new();
93        if !config.dynamic_profile_sources.is_empty() {
94            dynamic_profile_manager.start(&config.dynamic_profile_sources, &runtime);
95        }
96
97        Self {
98            windows: HashMap::new(),
99            menu: None,
100            config,
101            runtime,
102            should_exit: false,
103            pending_window_count: 0,
104            settings_window: None,
105            runtime_options,
106            start_time: None,
107            command_sent: false,
108            screenshot_taken: false,
109            update_checker: UpdateChecker::new(env!("CARGO_PKG_VERSION")),
110            next_update_check: None,
111            last_update_result: None,
112            arrangement_manager,
113            auto_restore_done: false,
114            dynamic_profile_manager,
115        }
116    }
117
118    /// Get the ID of the currently focused window.
119    /// Returns the window with `is_focused == true`, or falls back to the first window if none is focused.
120    pub fn get_focused_window_id(&self) -> Option<WindowId> {
121        // Find the window that has focus
122        for (window_id, window_state) in &self.windows {
123            if window_state.is_focused {
124                return Some(*window_id);
125            }
126        }
127        // Fallback: return the first window if no window claims focus
128        // This can happen briefly during window creation/destruction
129        self.windows.keys().next().copied()
130    }
131
132    /// Check and handle timing-based CLI options (exit-after, screenshot, command)
133    pub fn check_cli_timers(&mut self) {
134        let Some(start_time) = self.start_time else {
135            return;
136        };
137
138        let elapsed = start_time.elapsed().as_secs_f64();
139
140        // Send command after 1 second delay
141        if !self.command_sent
142            && elapsed >= 1.0
143            && let Some(cmd) = self.runtime_options.command_to_send.clone()
144        {
145            self.send_command_to_shell(&cmd);
146            self.command_sent = true;
147        }
148
149        // Take screenshot if requested (after exit_after - 1 second, or after 2 seconds if no exit_after)
150        if !self.screenshot_taken && self.runtime_options.screenshot.is_some() {
151            let screenshot_time = self
152                .runtime_options
153                .exit_after
154                .map(|t| t - 1.0)
155                .unwrap_or(2.0);
156            if elapsed >= screenshot_time {
157                self.take_screenshot();
158                self.screenshot_taken = true;
159            }
160        }
161
162        // Exit after specified time
163        if let Some(exit_after) = self.runtime_options.exit_after
164            && elapsed >= exit_after
165        {
166            log::info!("Exit-after timer expired ({:.1}s), exiting", exit_after);
167            self.should_exit = true;
168        }
169    }
170
171    /// Check for updates (called periodically from about_to_wait)
172    pub fn check_for_updates(&mut self) {
173        use crate::update_checker::current_timestamp;
174        use std::time::Duration;
175
176        let now = Instant::now();
177
178        // Schedule initial check shortly after startup (5 seconds delay)
179        if self.next_update_check.is_none() {
180            self.next_update_check = Some(now + Duration::from_secs(5));
181            return;
182        }
183
184        // Check if it's time for scheduled check
185        if let Some(next_check) = self.next_update_check
186            && now >= next_check
187        {
188            // Perform the check
189            let (result, should_save) = self.update_checker.check_now(&self.config, false);
190
191            // Log the result and notify if appropriate
192            let mut config_changed = should_save;
193            match &result {
194                UpdateCheckResult::UpdateAvailable(info) => {
195                    let version_str = info
196                        .version
197                        .strip_prefix('v')
198                        .unwrap_or(&info.version)
199                        .to_string();
200
201                    log::info!(
202                        "Update available: {} (current: {})",
203                        version_str,
204                        env!("CARGO_PKG_VERSION")
205                    );
206
207                    // Only notify if we haven't already notified about this version
208                    let already_notified = self
209                        .config
210                        .last_notified_version
211                        .as_ref()
212                        .is_some_and(|v| v == &version_str);
213
214                    if !already_notified {
215                        self.notify_update_available(info);
216                        self.config.last_notified_version = Some(version_str);
217                        config_changed = true;
218                    }
219                }
220                UpdateCheckResult::UpToDate => {
221                    log::info!("par-term is up to date ({})", env!("CARGO_PKG_VERSION"));
222                }
223                UpdateCheckResult::Error(e) => {
224                    log::warn!("Update check failed: {}", e);
225                }
226                UpdateCheckResult::Disabled | UpdateCheckResult::Skipped => {
227                    // Silent
228                }
229            }
230
231            self.last_update_result = Some(result);
232
233            // Save config with updated timestamp if check was successful
234            if config_changed {
235                self.config.last_update_check = Some(current_timestamp());
236                if let Err(e) = self.config.save() {
237                    log::warn!("Failed to save config after update check: {}", e);
238                }
239            }
240
241            // Schedule next check based on frequency
242            self.next_update_check = self
243                .config
244                .update_check_frequency
245                .as_seconds()
246                .map(|secs| now + Duration::from_secs(secs));
247        }
248    }
249
250    /// Show desktop notification when update is available
251    fn notify_update_available(&self, info: &crate::update_checker::UpdateInfo) {
252        let version_str = info.version.strip_prefix('v').unwrap_or(&info.version);
253        let current = env!("CARGO_PKG_VERSION");
254        let summary = format!("par-term v{} Available", version_str);
255        let body = format!(
256            "You have v{}. Check Settings > Advanced > Updates.",
257            current
258        );
259
260        #[cfg(not(target_os = "macos"))]
261        {
262            use notify_rust::Notification;
263            let _ = Notification::new()
264                .summary(&summary)
265                .body(&body)
266                .appname("par-term")
267                .timeout(notify_rust::Timeout::Milliseconds(8000))
268                .show();
269        }
270
271        #[cfg(target_os = "macos")]
272        {
273            let script = format!(
274                r#"display notification "{}" with title "{}""#,
275                body.replace('"', r#"\""#),
276                summary.replace('"', r#"\""#),
277            );
278            let _ = std::process::Command::new("osascript")
279                .arg("-e")
280                .arg(&script)
281                .spawn();
282        }
283    }
284
285    /// Force an immediate update check (triggered from UI)
286    pub fn force_update_check(&mut self) {
287        use crate::update_checker::current_timestamp;
288
289        let (result, should_save) = self.update_checker.check_now(&self.config, true);
290
291        // Log the result
292        match &result {
293            UpdateCheckResult::UpdateAvailable(info) => {
294                log::info!(
295                    "Update available: {} (current: {})",
296                    info.version,
297                    env!("CARGO_PKG_VERSION")
298                );
299            }
300            UpdateCheckResult::UpToDate => {
301                log::info!("par-term is up to date ({})", env!("CARGO_PKG_VERSION"));
302            }
303            UpdateCheckResult::Error(e) => {
304                log::warn!("Update check failed: {}", e);
305            }
306            _ => {}
307        }
308
309        self.last_update_result = Some(result);
310
311        // Save config with updated timestamp
312        if should_save {
313            self.config.last_update_check = Some(current_timestamp());
314            if let Err(e) = self.config.save() {
315                log::warn!("Failed to save config after update check: {}", e);
316            }
317        }
318    }
319
320    /// Force an update check and sync the result to the settings window.
321    pub fn force_update_check_for_settings(&mut self) {
322        self.force_update_check();
323        // Sync the result to the settings window
324        if let Some(settings_window) = &mut self.settings_window {
325            settings_window.settings_ui.last_update_result = self
326                .last_update_result
327                .as_ref()
328                .map(to_settings_update_result);
329            settings_window.request_redraw();
330        }
331    }
332
333    /// Send a command to the shell
334    fn send_command_to_shell(&mut self, cmd: &str) {
335        // Send to the first window's active tab
336        if let Some(window_state) = self.windows.values_mut().next()
337            && let Some(tab) = window_state.tab_manager.active_tab_mut()
338        {
339            // Send the command followed by Enter
340            let cmd_with_enter = format!("{}\n", cmd);
341            if let Ok(term) = tab.terminal.try_lock() {
342                if let Err(e) = term.write(cmd_with_enter.as_bytes()) {
343                    log::error!("Failed to send command to shell: {}", e);
344                } else {
345                    log::info!("Sent command to shell: {}", cmd);
346                }
347            }
348        }
349    }
350
351    /// Take a screenshot
352    fn take_screenshot(&mut self) {
353        log::info!("Taking screenshot...");
354
355        // Determine output path
356        let output_path = match &self.runtime_options.screenshot {
357            Some(path) if !path.as_os_str().is_empty() => {
358                log::info!("Screenshot path specified: {:?}", path);
359                path.clone()
360            }
361            _ => {
362                // Generate timestamped filename
363                let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
364                let path = PathBuf::from(format!("par-term-{}.png", timestamp));
365                log::info!("Using auto-generated screenshot path: {:?}", path);
366                path
367            }
368        };
369
370        // Get the first window and take screenshot
371        if let Some(window_state) = self.windows.values_mut().next() {
372            if let Some(renderer) = &mut window_state.renderer {
373                log::info!("Capturing screenshot from renderer...");
374                match renderer.take_screenshot() {
375                    Ok(image_data) => {
376                        log::info!(
377                            "Screenshot captured: {}x{} pixels",
378                            image_data.width(),
379                            image_data.height()
380                        );
381                        // Save the image
382                        if let Err(e) = image_data.save(&output_path) {
383                            log::error!("Failed to save screenshot to {:?}: {}", output_path, e);
384                        } else {
385                            log::info!("Screenshot saved to {:?}", output_path);
386                        }
387                    }
388                    Err(e) => {
389                        log::error!("Failed to take screenshot: {}", e);
390                    }
391                }
392            } else {
393                log::warn!("No renderer available for screenshot");
394            }
395        } else {
396            log::warn!("No window available for screenshot");
397        }
398    }
399
400    /// Create a new window with a fresh terminal session
401    pub fn create_window(&mut self, event_loop: &ActiveEventLoop) {
402        use crate::config::WindowType;
403        use crate::font_metrics::window_size_from_config;
404        use winit::window::Window;
405
406        // Reload config from disk to pick up any changes made by other windows
407        // (e.g., integration versions saved after completing onboarding).
408        // This ensures new windows don't show stale prompts.
409        if let Ok(fresh_config) = Config::load() {
410            self.config = fresh_config;
411        }
412
413        // Calculate window size from cols/rows BEFORE window creation.
414        // This ensures the window opens at the exact correct size with no visible resize.
415        // We use scale_factor=1.0 here since we don't have the actual display scale yet;
416        // the window will be resized correctly once we know the actual scale factor.
417        // Fallback to reasonable defaults (800x600) if font metrics calculation fails.
418        let (width, height) = window_size_from_config(&self.config, 1.0).unwrap_or((800, 600));
419
420        // Build window title, optionally including window number
421        let window_number = self.windows.len() + 1;
422        let title = if self.config.show_window_number {
423            format!("{} [{}]", self.config.window_title, window_number)
424        } else {
425            self.config.window_title.clone()
426        };
427
428        let mut window_attrs = Window::default_attributes()
429            .with_title(&title)
430            .with_inner_size(winit::dpi::LogicalSize::new(width, height))
431            .with_decorations(self.config.window_decorations);
432
433        // Lock window size if requested (prevent resize)
434        if self.config.lock_window_size {
435            window_attrs = window_attrs.with_resizable(false);
436            log::info!("Window size locked (resizing disabled)");
437        }
438
439        // Start in fullscreen if window_type is Fullscreen
440        if self.config.window_type == WindowType::Fullscreen {
441            window_attrs =
442                window_attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
443            log::info!("Window starting in fullscreen mode");
444        }
445
446        // Load and set the application icon
447        let icon_bytes = include_bytes!("../../assets/icon.png");
448        if let Ok(icon_image) = image::load_from_memory(icon_bytes) {
449            let rgba = icon_image.to_rgba8();
450            let (width, height) = rgba.dimensions();
451            if let Ok(icon) = winit::window::Icon::from_rgba(rgba.into_raw(), width, height) {
452                window_attrs = window_attrs.with_window_icon(Some(icon));
453                log::info!("Window icon set ({}x{})", width, height);
454            } else {
455                log::warn!("Failed to create window icon from RGBA data");
456            }
457        } else {
458            log::warn!("Failed to load embedded icon image");
459        }
460
461        // Set window always-on-top if requested
462        if self.config.window_always_on_top {
463            window_attrs = window_attrs.with_window_level(winit::window::WindowLevel::AlwaysOnTop);
464            log::info!("Window always-on-top enabled");
465        }
466
467        // Always enable window transparency support for runtime opacity changes
468        window_attrs = window_attrs.with_transparent(true);
469        log::info!(
470            "Window transparency enabled (opacity: {})",
471            self.config.window_opacity
472        );
473
474        match event_loop.create_window(window_attrs) {
475            Ok(window) => {
476                let window_id = window.id();
477                let mut window_state =
478                    WindowState::new(self.config.clone(), Arc::clone(&self.runtime));
479                // Set window index for title formatting (window_number calculated earlier)
480                window_state.window_index = window_number;
481
482                // Initialize async components using the shared runtime
483                let runtime = Arc::clone(&self.runtime);
484                if let Err(e) = runtime.block_on(window_state.initialize_async(window)) {
485                    log::error!("Failed to initialize window: {}", e);
486                    return;
487                }
488
489                // Initialize menu for the first window (macOS global menu) or per-window (Windows/Linux)
490                if self.menu.is_none() {
491                    match MenuManager::new() {
492                        Ok(menu) => {
493                            // Attach menu to window (platform-specific)
494                            if let Some(win) = &window_state.window
495                                && let Err(e) = menu.init_for_window(win)
496                            {
497                                log::warn!("Failed to initialize menu for window: {}", e);
498                            }
499                            self.menu = Some(menu);
500                        }
501                        Err(e) => {
502                            log::warn!("Failed to create menu: {}", e);
503                        }
504                    }
505                } else if let Some(menu) = &self.menu
506                    && let Some(win) = &window_state.window
507                    && let Err(e) = menu.init_for_window(win)
508                {
509                    // For additional windows on Windows/Linux, attach menu
510                    log::warn!("Failed to initialize menu for window: {}", e);
511                }
512
513                // Apply target monitor and edge positioning after window creation
514                if let Some(win) = &window_state.window {
515                    self.apply_window_positioning(win, event_loop);
516                }
517
518                // Handle tmux auto-attach on first window only
519                if self.windows.is_empty()
520                    && window_state.config.tmux_enabled
521                    && window_state.config.tmux_auto_attach
522                {
523                    let session_name = window_state.config.tmux_auto_attach_session.clone();
524
525                    // Use gateway mode: writes tmux commands to existing PTY
526                    if let Some(ref name) = session_name {
527                        if !name.is_empty() {
528                            log::info!(
529                                "tmux auto-attach: attempting to attach to session '{}' via gateway",
530                                name
531                            );
532                            match window_state.attach_tmux_gateway(name) {
533                                Ok(()) => {
534                                    log::info!(
535                                        "tmux auto-attach: gateway initiated for session '{}'",
536                                        name
537                                    );
538                                }
539                                Err(e) => {
540                                    log::warn!(
541                                        "tmux auto-attach: failed to attach to '{}': {} - continuing without tmux",
542                                        name,
543                                        e
544                                    );
545                                    // Continue without tmux - don't fail startup
546                                }
547                            }
548                        } else {
549                            // Empty string means create new session
550                            log::info!(
551                                "tmux auto-attach: no session specified, creating new session via gateway"
552                            );
553                            if let Err(e) = window_state.initiate_tmux_gateway(None) {
554                                log::warn!(
555                                    "tmux auto-attach: failed to create new session: {} - continuing without tmux",
556                                    e
557                                );
558                            }
559                        }
560                    } else {
561                        // None means create new session
562                        log::info!(
563                            "tmux auto-attach: no session specified, creating new session via gateway"
564                        );
565                        if let Err(e) = window_state.initiate_tmux_gateway(None) {
566                            log::warn!(
567                                "tmux auto-attach: failed to create new session: {} - continuing without tmux",
568                                e
569                            );
570                        }
571                    }
572                }
573
574                self.windows.insert(window_id, window_state);
575                self.pending_window_count += 1;
576
577                // Set start time on first window creation (for CLI timers)
578                if self.start_time.is_none() {
579                    self.start_time = Some(Instant::now());
580                }
581
582                log::info!(
583                    "Created new window {:?} (total: {})",
584                    window_id,
585                    self.windows.len()
586                );
587            }
588            Err(e) => {
589                log::error!("Failed to create window: {}", e);
590            }
591        }
592    }
593
594    /// Apply window positioning based on config (target monitor and edge anchoring)
595    fn apply_window_positioning(
596        &self,
597        window: &std::sync::Arc<winit::window::Window>,
598        event_loop: &ActiveEventLoop,
599    ) {
600        use crate::config::WindowType;
601
602        // Get list of available monitors
603        let monitors: Vec<_> = event_loop.available_monitors().collect();
604        if monitors.is_empty() {
605            log::warn!("No monitors available for window positioning");
606            return;
607        }
608
609        // Select target monitor (default to primary/first)
610        let monitor = if let Some(index) = self.config.target_monitor {
611            monitors
612                .get(index)
613                .cloned()
614                .or_else(|| monitors.first().cloned())
615        } else {
616            event_loop
617                .primary_monitor()
618                .or_else(|| monitors.first().cloned())
619        };
620
621        let Some(monitor) = monitor else {
622            log::warn!("Could not determine target monitor");
623            return;
624        };
625
626        let monitor_pos = monitor.position();
627        let monitor_size = monitor.size();
628        let window_size = window.outer_size();
629
630        // Apply edge positioning if configured
631        match self.config.window_type {
632            WindowType::EdgeTop => {
633                // Position at top of screen, spanning full width
634                window.set_outer_position(winit::dpi::PhysicalPosition::new(
635                    monitor_pos.x,
636                    monitor_pos.y,
637                ));
638                let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(
639                    monitor_size.width,
640                    window_size.height,
641                ));
642                log::info!("Window positioned at top edge of monitor");
643            }
644            WindowType::EdgeBottom => {
645                // Position at bottom of screen, spanning full width
646                let y = monitor_pos.y + monitor_size.height as i32 - window_size.height as i32;
647                window.set_outer_position(winit::dpi::PhysicalPosition::new(monitor_pos.x, y));
648                let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(
649                    monitor_size.width,
650                    window_size.height,
651                ));
652                log::info!("Window positioned at bottom edge of monitor");
653            }
654            WindowType::EdgeLeft => {
655                // Position at left of screen, spanning full height
656                window.set_outer_position(winit::dpi::PhysicalPosition::new(
657                    monitor_pos.x,
658                    monitor_pos.y,
659                ));
660                let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(
661                    window_size.width,
662                    monitor_size.height,
663                ));
664                log::info!("Window positioned at left edge of monitor");
665            }
666            WindowType::EdgeRight => {
667                // Position at right of screen, spanning full height
668                let x = monitor_pos.x + monitor_size.width as i32 - window_size.width as i32;
669                window.set_outer_position(winit::dpi::PhysicalPosition::new(x, monitor_pos.y));
670                let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(
671                    window_size.width,
672                    monitor_size.height,
673                ));
674                log::info!("Window positioned at right edge of monitor");
675            }
676            WindowType::Normal | WindowType::Fullscreen => {
677                // For normal/fullscreen, just position on target monitor if specified
678                if self.config.target_monitor.is_some() {
679                    // Center window on target monitor
680                    let x =
681                        monitor_pos.x + (monitor_size.width as i32 - window_size.width as i32) / 2;
682                    let y = monitor_pos.y
683                        + (monitor_size.height as i32 - window_size.height as i32) / 2;
684                    window.set_outer_position(winit::dpi::PhysicalPosition::new(x, y));
685                    log::info!(
686                        "Window centered on monitor {} at ({}, {})",
687                        self.config.target_monitor.unwrap_or(0),
688                        x,
689                        y
690                    );
691                }
692            }
693        }
694
695        // Move window to target macOS Space if configured (macOS only, no-op on other platforms)
696        if let Some(space) = self.config.target_space
697            && let Err(e) = crate::macos_space::move_window_to_space(window, space)
698        {
699            log::warn!("Failed to move window to Space {}: {}", space, e);
700        }
701    }
702
703    /// Close a specific window
704    pub fn close_window(&mut self, window_id: WindowId) {
705        // Save session state before removing the last window (while data is still available).
706        // Capture happens synchronously (fast, in-memory), disk write is on a background thread.
707        if self.config.restore_session
708            && self.windows.len() == 1
709            && self.windows.contains_key(&window_id)
710        {
711            self.save_session_state_background();
712        }
713
714        if let Some(window_state) = self.windows.remove(&window_id) {
715            log::info!(
716                "Closing window {:?} (remaining: {})",
717                window_id,
718                self.windows.len()
719            );
720            // Hide window immediately for instant visual feedback
721            if let Some(ref window) = window_state.window {
722                window.set_visible(false);
723            }
724            // WindowState's Drop impl handles cleanup
725            drop(window_state);
726        }
727
728        // Exit app when last window closes
729        if self.windows.is_empty() {
730            log::info!("Last window closed, exiting application");
731            // Close settings window FIRST before marking exit
732            // This ensures settings window resources are cleaned up before app teardown
733            if self.settings_window.is_some() {
734                log::info!("Closing settings window before exit");
735                self.close_settings_window();
736            }
737            self.should_exit = true;
738        }
739    }
740
741    /// Save session state on a background thread to avoid blocking the main thread.
742    /// Captures state synchronously (fast, in-memory) then spawns disk I/O.
743    fn save_session_state_background(&self) {
744        let state = crate::session::capture::capture_session(&self.windows);
745        let _ = std::thread::Builder::new()
746            .name("session-save".into())
747            .spawn(move || {
748                if let Err(e) = crate::session::storage::save_session(&state) {
749                    log::error!("Failed to save session state: {}", e);
750                }
751            });
752    }
753
754    /// Restore windows from the last saved session
755    ///
756    /// Returns true if session was successfully restored, false otherwise.
757    pub fn restore_session(&mut self, event_loop: &ActiveEventLoop) -> bool {
758        let session = match crate::session::storage::load_session() {
759            Ok(Some(session)) => session,
760            Ok(None) => {
761                log::info!("No saved session found, creating default window");
762                return false;
763            }
764            Err(e) => {
765                log::warn!(
766                    "Failed to load session state: {}, creating default window",
767                    e
768                );
769                return false;
770            }
771        };
772
773        if session.windows.is_empty() {
774            log::info!("Saved session has no windows, creating default window");
775            return false;
776        }
777
778        log::info!(
779            "Restoring session ({} windows) saved at {}",
780            session.windows.len(),
781            session.saved_at
782        );
783
784        for session_window in &session.windows {
785            // Validate CWDs for tabs
786            let tab_cwds: Vec<Option<String>> = session_window
787                .tabs
788                .iter()
789                .map(|tab| crate::session::restore::validate_cwd(&tab.cwd))
790                .collect();
791
792            self.create_window_with_overrides(
793                event_loop,
794                session_window.position,
795                session_window.size,
796                &tab_cwds,
797                session_window.active_tab_index,
798            );
799
800            // Restore pane layouts for tabs that had splits
801            // Find the window we just created (it's the most recently added one)
802            if let Some((_window_id, window_state)) = self.windows.iter_mut().last() {
803                let tabs = window_state.tab_manager.tabs_mut();
804                for (tab_idx, session_tab) in session_window.tabs.iter().enumerate() {
805                    if let Some(ref layout) = session_tab.pane_layout
806                        && let Some(tab) = tabs.get_mut(tab_idx)
807                    {
808                        tab.restore_pane_layout(layout, &self.config, Arc::clone(&self.runtime));
809                    }
810                }
811            }
812        }
813
814        // Clear the saved session file after successful restore
815        if let Err(e) = crate::session::storage::clear_session() {
816            log::warn!("Failed to clear session file after restore: {}", e);
817        }
818
819        // If no windows were created (shouldn't happen), fall back
820        if self.windows.is_empty() {
821            log::warn!("Session restore created no windows, creating default");
822            return false;
823        }
824
825        true
826    }
827
828    /// Get mutable reference to a window's state
829    #[allow(dead_code)]
830    pub fn get_window_mut(&mut self, window_id: WindowId) -> Option<&mut WindowState> {
831        self.windows.get_mut(&window_id)
832    }
833
834    /// Get reference to a window's state
835    #[allow(dead_code)]
836    pub fn get_window(&self, window_id: WindowId) -> Option<&WindowState> {
837        self.windows.get(&window_id)
838    }
839
840    /// Handle a menu action
841    pub fn handle_menu_action(
842        &mut self,
843        action: MenuAction,
844        event_loop: &ActiveEventLoop,
845        focused_window: Option<WindowId>,
846    ) {
847        match action {
848            MenuAction::NewWindow => {
849                self.create_window(event_loop);
850            }
851            MenuAction::CloseWindow => {
852                // Smart close: close tab if multiple tabs, close window if single tab
853                if let Some(window_id) = focused_window
854                    && let Some(window_state) = self.windows.get_mut(&window_id)
855                    && window_state.close_current_tab()
856                {
857                    // Last tab closed, close the window
858                    self.close_window(window_id);
859                }
860            }
861            MenuAction::NewTab => {
862                if let Some(window_id) = focused_window
863                    && let Some(window_state) = self.windows.get_mut(&window_id)
864                {
865                    window_state.new_tab();
866                }
867            }
868            MenuAction::CloseTab => {
869                if let Some(window_id) = focused_window
870                    && let Some(window_state) = self.windows.get_mut(&window_id)
871                    && window_state.close_current_tab()
872                {
873                    // Last tab closed, close the window
874                    self.close_window(window_id);
875                }
876            }
877            MenuAction::NextTab => {
878                if let Some(window_id) = focused_window
879                    && let Some(window_state) = self.windows.get_mut(&window_id)
880                {
881                    window_state.next_tab();
882                }
883            }
884            MenuAction::PreviousTab => {
885                if let Some(window_id) = focused_window
886                    && let Some(window_state) = self.windows.get_mut(&window_id)
887                {
888                    window_state.prev_tab();
889                }
890            }
891            MenuAction::SwitchToTab(index) => {
892                if let Some(window_id) = focused_window
893                    && let Some(window_state) = self.windows.get_mut(&window_id)
894                {
895                    window_state.switch_to_tab_index(index);
896                }
897            }
898            MenuAction::MoveTabLeft => {
899                if let Some(window_id) = focused_window
900                    && let Some(window_state) = self.windows.get_mut(&window_id)
901                {
902                    window_state.move_tab_left();
903                }
904            }
905            MenuAction::MoveTabRight => {
906                if let Some(window_id) = focused_window
907                    && let Some(window_state) = self.windows.get_mut(&window_id)
908                {
909                    window_state.move_tab_right();
910                }
911            }
912            MenuAction::DuplicateTab => {
913                if let Some(window_id) = focused_window
914                    && let Some(window_state) = self.windows.get_mut(&window_id)
915                {
916                    window_state.duplicate_tab();
917                }
918            }
919            MenuAction::Quit => {
920                // Close all windows
921                let window_ids: Vec<_> = self.windows.keys().copied().collect();
922                for window_id in window_ids {
923                    self.close_window(window_id);
924                }
925            }
926            MenuAction::Copy => {
927                // If settings window is focused, inject copy event into egui
928                if let Some(sw) = &self.settings_window
929                    && sw.is_focused()
930                {
931                    if let Some(sw) = &mut self.settings_window {
932                        sw.inject_event(egui::Event::Copy);
933                    }
934                    return;
935                }
936                // If an egui overlay (profile modal, search, etc.) is active, inject into main egui
937                if let Some(window_id) = focused_window
938                    && let Some(window_state) = self.windows.get_mut(&window_id)
939                    && window_state.has_egui_overlay_visible()
940                {
941                    window_state.pending_egui_events.push(egui::Event::Copy);
942                    return;
943                }
944                if let Some(window_id) = focused_window
945                    && let Some(window_state) = self.windows.get_mut(&window_id)
946                    && let Some(text) = window_state.get_selected_text()
947                {
948                    if let Err(e) = window_state.input_handler.copy_to_clipboard(&text) {
949                        log::error!("Failed to copy to clipboard: {}", e);
950                    } else {
951                        // Sync to tmux paste buffer if connected
952                        window_state.sync_clipboard_to_tmux(&text);
953                    }
954                }
955            }
956            MenuAction::Paste => {
957                // If settings window is focused, inject paste into its egui context
958                // (macOS menu accelerator intercepts Cmd+V before egui sees it)
959                if let Some(sw) = &self.settings_window
960                    && sw.is_focused()
961                {
962                    if let Ok(mut clipboard) = arboard::Clipboard::new() {
963                        if let Ok(text) = clipboard.get_text() {
964                            if let Some(sw) = &mut self.settings_window {
965                                sw.inject_paste(text);
966                            }
967                            return;
968                        }
969                        // Clipboard has no text — check for image below.
970                        // Don't return early so the image-paste forwarding
971                        // code can send Ctrl+V to the terminal.
972                        if clipboard.get_image().is_err() {
973                            // Neither text nor image — nothing to paste
974                            return;
975                        }
976                    } else {
977                        return;
978                    }
979                }
980                // If an egui overlay (profile modal, search, etc.) is active, inject into main egui
981                if let Some(window_id) = focused_window
982                    && let Some(window_state) = self.windows.get_mut(&window_id)
983                    && window_state.has_egui_overlay_visible()
984                {
985                    if let Ok(mut clipboard) = arboard::Clipboard::new() {
986                        if let Ok(text) = clipboard.get_text() {
987                            window_state
988                                .pending_egui_events
989                                .push(egui::Event::Paste(text));
990                            return;
991                        }
992                        // Clipboard has no text — fall through to check for image
993                        // so it can be forwarded to the terminal
994                        if clipboard.get_image().is_err() {
995                            return;
996                        }
997                    } else {
998                        return;
999                    }
1000                }
1001                if let Some(window_id) = focused_window
1002                    && let Some(window_state) = self.windows.get_mut(&window_id)
1003                {
1004                    if let Some(text) = window_state.input_handler.paste_from_clipboard() {
1005                        window_state.paste_text(&text);
1006                    } else if window_state.input_handler.clipboard_has_image() {
1007                        // Clipboard has an image but no text — forward as Ctrl+V (0x16) so
1008                        // image-aware child processes (e.g., Claude Code) can handle image paste
1009                        if let Some(tab) = window_state.tab_manager.active_tab() {
1010                            let terminal_clone = Arc::clone(&tab.terminal);
1011                            window_state.runtime.spawn(async move {
1012                                let term = terminal_clone.lock().await;
1013                                let _ = term.write(b"\x16");
1014                            });
1015                        }
1016                    }
1017                }
1018            }
1019            MenuAction::SelectAll => {
1020                // If settings window is focused, inject select-all into egui
1021                if let Some(sw) = &self.settings_window
1022                    && sw.is_focused()
1023                {
1024                    if let Some(sw) = &mut self.settings_window {
1025                        // egui has no dedicated SelectAll event; use Cmd+A key event
1026                        sw.inject_event(egui::Event::Key {
1027                            key: egui::Key::A,
1028                            physical_key: None,
1029                            pressed: true,
1030                            repeat: false,
1031                            modifiers: egui::Modifiers::COMMAND,
1032                        });
1033                    }
1034                    return;
1035                }
1036                // If an egui overlay is active, inject select-all into main egui
1037                if let Some(window_id) = focused_window
1038                    && let Some(window_state) = self.windows.get_mut(&window_id)
1039                    && window_state.has_egui_overlay_visible()
1040                {
1041                    window_state.pending_egui_events.push(egui::Event::Key {
1042                        key: egui::Key::A,
1043                        physical_key: None,
1044                        pressed: true,
1045                        repeat: false,
1046                        modifiers: egui::Modifiers::COMMAND,
1047                    });
1048                    return;
1049                }
1050                // Not implemented for terminal - would select all visible text
1051                log::debug!("SelectAll menu action (not implemented for terminal)");
1052            }
1053            MenuAction::ClearScrollback => {
1054                if let Some(window_id) = focused_window
1055                    && let Some(window_state) = self.windows.get_mut(&window_id)
1056                {
1057                    // Clear scrollback in active tab
1058                    let cleared = if let Some(tab) = window_state.tab_manager.active_tab_mut() {
1059                        if let Ok(mut term) = tab.terminal.try_lock() {
1060                            term.clear_scrollback();
1061                            term.clear_scrollback_metadata();
1062                            tab.cache.scrollback_len = 0;
1063                            tab.trigger_marks.clear();
1064                            true
1065                        } else {
1066                            false
1067                        }
1068                    } else {
1069                        false
1070                    };
1071
1072                    if cleared {
1073                        window_state.set_scroll_target(0);
1074                        log::info!("Cleared scrollback buffer");
1075                    }
1076                }
1077            }
1078            MenuAction::ClipboardHistory => {
1079                if let Some(window_id) = focused_window
1080                    && let Some(window_state) = self.windows.get_mut(&window_id)
1081                {
1082                    window_state.clipboard_history_ui.toggle();
1083                    window_state.needs_redraw = true;
1084                }
1085            }
1086            MenuAction::ToggleFullscreen => {
1087                if let Some(window_id) = focused_window
1088                    && let Some(window_state) = self.windows.get_mut(&window_id)
1089                    && let Some(window) = &window_state.window
1090                {
1091                    window_state.is_fullscreen = !window_state.is_fullscreen;
1092                    if window_state.is_fullscreen {
1093                        window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
1094                    } else {
1095                        window.set_fullscreen(None);
1096                    }
1097                }
1098            }
1099            MenuAction::MaximizeVertically => {
1100                if let Some(window_id) = focused_window
1101                    && let Some(window_state) = self.windows.get_mut(&window_id)
1102                    && let Some(window) = &window_state.window
1103                {
1104                    // Get current monitor to determine screen height
1105                    if let Some(monitor) = window.current_monitor() {
1106                        let monitor_pos = monitor.position();
1107                        let monitor_size = monitor.size();
1108                        let window_pos = window.outer_position().unwrap_or_default();
1109                        let window_size = window.outer_size();
1110
1111                        // Set window to span full height while keeping current X position and width
1112                        window.set_outer_position(winit::dpi::PhysicalPosition::new(
1113                            window_pos.x,
1114                            monitor_pos.y,
1115                        ));
1116                        let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(
1117                            window_size.width,
1118                            monitor_size.height,
1119                        ));
1120                        log::info!(
1121                            "Window maximized vertically to {} pixels",
1122                            monitor_size.height
1123                        );
1124                    }
1125                }
1126            }
1127            MenuAction::IncreaseFontSize => {
1128                if let Some(window_id) = focused_window
1129                    && let Some(window_state) = self.windows.get_mut(&window_id)
1130                {
1131                    window_state.config.font_size = (window_state.config.font_size + 1.0).min(72.0);
1132                    window_state.pending_font_rebuild = true;
1133                    if let Some(window) = &window_state.window {
1134                        window.request_redraw();
1135                    }
1136                }
1137            }
1138            MenuAction::DecreaseFontSize => {
1139                if let Some(window_id) = focused_window
1140                    && let Some(window_state) = self.windows.get_mut(&window_id)
1141                {
1142                    window_state.config.font_size = (window_state.config.font_size - 1.0).max(6.0);
1143                    window_state.pending_font_rebuild = true;
1144                    if let Some(window) = &window_state.window {
1145                        window.request_redraw();
1146                    }
1147                }
1148            }
1149            MenuAction::ResetFontSize => {
1150                if let Some(window_id) = focused_window
1151                    && let Some(window_state) = self.windows.get_mut(&window_id)
1152                {
1153                    window_state.config.font_size = 14.0;
1154                    window_state.pending_font_rebuild = true;
1155                    if let Some(window) = &window_state.window {
1156                        window.request_redraw();
1157                    }
1158                }
1159            }
1160            MenuAction::ToggleFpsOverlay => {
1161                if let Some(window_id) = focused_window
1162                    && let Some(window_state) = self.windows.get_mut(&window_id)
1163                {
1164                    window_state.debug.show_fps_overlay = !window_state.debug.show_fps_overlay;
1165                    if let Some(window) = &window_state.window {
1166                        window.request_redraw();
1167                    }
1168                }
1169            }
1170            MenuAction::OpenSettings => {
1171                self.open_settings_window(event_loop);
1172            }
1173            MenuAction::Minimize => {
1174                if let Some(window_id) = focused_window
1175                    && let Some(window_state) = self.windows.get(&window_id)
1176                    && let Some(window) = &window_state.window
1177                {
1178                    window.set_minimized(true);
1179                }
1180            }
1181            MenuAction::Zoom => {
1182                if let Some(window_id) = focused_window
1183                    && let Some(window_state) = self.windows.get(&window_id)
1184                    && let Some(window) = &window_state.window
1185                {
1186                    window.set_maximized(!window.is_maximized());
1187                }
1188            }
1189            MenuAction::ShowHelp => {
1190                if let Some(window_id) = focused_window
1191                    && let Some(window_state) = self.windows.get_mut(&window_id)
1192                {
1193                    window_state.help_ui.toggle();
1194                    if let Some(window) = &window_state.window {
1195                        window.request_redraw();
1196                    }
1197                }
1198            }
1199            MenuAction::About => {
1200                log::info!("About par-term v{}", env!("CARGO_PKG_VERSION"));
1201                // Could show an about dialog here
1202            }
1203            MenuAction::ToggleBackgroundShader => {
1204                if let Some(window_id) = focused_window
1205                    && let Some(window_state) = self.windows.get_mut(&window_id)
1206                {
1207                    window_state.toggle_background_shader();
1208                }
1209            }
1210            MenuAction::ToggleCursorShader => {
1211                if let Some(window_id) = focused_window
1212                    && let Some(window_state) = self.windows.get_mut(&window_id)
1213                {
1214                    window_state.toggle_cursor_shader();
1215                }
1216            }
1217            MenuAction::ReloadConfig => {
1218                if let Some(window_id) = focused_window
1219                    && let Some(window_state) = self.windows.get_mut(&window_id)
1220                {
1221                    window_state.reload_config();
1222                }
1223            }
1224            MenuAction::ManageProfiles => {
1225                self.open_settings_window(event_loop);
1226                if let Some(sw) = &mut self.settings_window {
1227                    sw.settings_ui
1228                        .set_selected_tab(crate::settings_ui::sidebar::SettingsTab::Profiles);
1229                }
1230            }
1231            MenuAction::ToggleProfileDrawer => {
1232                if let Some(window_id) = focused_window
1233                    && let Some(window_state) = self.windows.get_mut(&window_id)
1234                {
1235                    window_state.toggle_profile_drawer();
1236                }
1237            }
1238            MenuAction::OpenProfile(profile_id) => {
1239                if let Some(window_id) = focused_window
1240                    && let Some(window_state) = self.windows.get_mut(&window_id)
1241                {
1242                    window_state.open_profile(profile_id);
1243                }
1244            }
1245            MenuAction::SaveArrangement => {
1246                // Open settings window to the Arrangements tab
1247                self.open_settings_window(event_loop);
1248                if let Some(sw) = &mut self.settings_window {
1249                    sw.settings_ui
1250                        .set_selected_tab(crate::settings_ui::sidebar::SettingsTab::Arrangements);
1251                }
1252            }
1253            MenuAction::InstallShellIntegrationRemote => {
1254                if let Some(window_id) = focused_window
1255                    && let Some(window_state) = self.windows.get_mut(&window_id)
1256                {
1257                    window_state.remote_shell_install_ui.show_dialog();
1258                    window_state.needs_redraw = true;
1259                }
1260            }
1261        }
1262    }
1263
1264    /// Process any pending menu events
1265    pub fn process_menu_events(
1266        &mut self,
1267        event_loop: &ActiveEventLoop,
1268        focused_window: Option<WindowId>,
1269    ) {
1270        if let Some(menu) = &self.menu {
1271            // Collect actions to avoid borrow conflicts
1272            let actions: Vec<_> = menu.poll_events().collect();
1273            for action in actions {
1274                self.handle_menu_action(action, event_loop, focused_window);
1275            }
1276        }
1277    }
1278
1279    /// Open the settings window (or focus if already open)
1280    pub fn open_settings_window(&mut self, event_loop: &ActiveEventLoop) {
1281        // If already open, bring to front and focus
1282        if let Some(settings_window) = &self.settings_window {
1283            settings_window.focus();
1284            return;
1285        }
1286
1287        // Create new settings window using shared runtime
1288        let config = self.config.clone();
1289        let runtime = Arc::clone(&self.runtime);
1290
1291        // Get supported vsync modes from the first window's renderer
1292        let supported_vsync_modes: Vec<crate::config::VsyncMode> = self
1293            .windows
1294            .values()
1295            .next()
1296            .and_then(|ws| ws.renderer.as_ref())
1297            .map(|renderer| {
1298                [
1299                    crate::config::VsyncMode::Immediate,
1300                    crate::config::VsyncMode::Mailbox,
1301                    crate::config::VsyncMode::Fifo,
1302                ]
1303                .into_iter()
1304                .filter(|mode| renderer.is_vsync_mode_supported(*mode))
1305                .collect()
1306            })
1307            .unwrap_or_else(|| vec![crate::config::VsyncMode::Fifo]); // Fifo always supported
1308
1309        match runtime.block_on(SettingsWindow::new(
1310            event_loop,
1311            config,
1312            supported_vsync_modes,
1313        )) {
1314            Ok(mut settings_window) => {
1315                log::info!("Opened settings window {:?}", settings_window.window_id());
1316                // Set app version from main crate (env! expands to the correct version here)
1317                settings_window.settings_ui.app_version = env!("CARGO_PKG_VERSION");
1318                // Wire up shell integration fn pointers
1319                settings_window
1320                    .settings_ui
1321                    .shell_integration_detected_shell_fn =
1322                    Some(crate::shell_integration_installer::detected_shell);
1323                settings_window
1324                    .settings_ui
1325                    .shell_integration_is_installed_fn =
1326                    Some(crate::shell_integration_installer::is_installed);
1327                // Sync last update check result to settings UI
1328                settings_window.settings_ui.last_update_result = self
1329                    .last_update_result
1330                    .as_ref()
1331                    .map(to_settings_update_result);
1332                // Sync profiles from first window's profile manager
1333                let profiles = self
1334                    .windows
1335                    .values()
1336                    .next()
1337                    .map(|ws| ws.profile_manager.to_vec())
1338                    .unwrap_or_default();
1339                settings_window.settings_ui.sync_profiles(profiles);
1340                // Sync available agents from first window's discovered agents
1341                if let Some(ws) = self.windows.values().next() {
1342                    settings_window.settings_ui.available_agent_ids = ws
1343                        .available_agents
1344                        .iter()
1345                        .map(|a| (a.identity.clone(), a.name.clone()))
1346                        .collect();
1347                }
1348                self.settings_window = Some(settings_window);
1349                // Sync arrangement data to settings UI
1350                self.sync_arrangements_to_settings();
1351            }
1352            Err(e) => {
1353                log::error!("Failed to create settings window: {}", e);
1354            }
1355        }
1356    }
1357
1358    /// Close the settings window
1359    pub fn close_settings_window(&mut self) {
1360        if let Some(settings_window) = self.settings_window.take() {
1361            // Persist collapsed section states without saving any unsaved preference changes.
1362            // Read the on-disk config and update only the collapsed sections field.
1363            let collapsed = settings_window.settings_ui.collapsed_sections_snapshot();
1364            if !collapsed.is_empty() || !self.config.collapsed_settings_sections.is_empty() {
1365                match Config::load() {
1366                    Ok(mut disk_config) => {
1367                        disk_config.collapsed_settings_sections = collapsed.clone();
1368                        if let Err(e) = disk_config.save() {
1369                            log::error!("Failed to persist settings section states: {}", e);
1370                        }
1371                    }
1372                    Err(e) => {
1373                        log::error!("Failed to load config for section state save: {}", e);
1374                    }
1375                }
1376                // Keep the in-memory config in sync too
1377                self.config.collapsed_settings_sections = collapsed;
1378            }
1379            log::info!("Closed settings window");
1380        }
1381    }
1382
1383    /// Check if a window ID belongs to the settings window
1384    pub fn is_settings_window(&self, window_id: WindowId) -> bool {
1385        self.settings_window
1386            .as_ref()
1387            .is_some_and(|sw| sw.window_id() == window_id)
1388    }
1389
1390    /// Handle an event for the settings window
1391    pub fn handle_settings_window_event(
1392        &mut self,
1393        event: WindowEvent,
1394    ) -> Option<SettingsWindowAction> {
1395        if let Some(settings_window) = &mut self.settings_window {
1396            let action = settings_window.handle_window_event(event);
1397
1398            // Handle close action
1399            if settings_window.should_close() {
1400                self.close_settings_window();
1401                return Some(SettingsWindowAction::Close);
1402            }
1403
1404            return Some(action);
1405        }
1406        None
1407    }
1408
1409    /// Apply config changes from settings window to all terminal windows
1410    pub fn apply_config_to_windows(&mut self, config: &Config) {
1411        use crate::app::config_updates::ConfigChanges;
1412
1413        // Apply log level change immediately
1414        crate::debug::set_log_level(config.log_level.to_level_filter());
1415
1416        // Track shader errors for the standalone settings window
1417        // Option<Option<String>>: None = no change attempted, Some(None) = success, Some(Some(err)) = error
1418        let mut last_shader_result: Option<Option<String>> = None;
1419        let mut last_cursor_shader_result: Option<Option<String>> = None;
1420
1421        for window_state in self.windows.values_mut() {
1422            // Detect what changed
1423            let changes = ConfigChanges::detect(&window_state.config, config);
1424
1425            // Update the config
1426            window_state.config = config.clone();
1427
1428            // Rebuild keybinding registry if keybindings changed
1429            if changes.keybindings {
1430                window_state.keybinding_registry =
1431                    crate::keybindings::KeybindingRegistry::from_config(&config.keybindings);
1432                log::info!(
1433                    "Keybinding registry rebuilt with {} bindings",
1434                    config.keybindings.len()
1435                );
1436            }
1437
1438            // Sync AI Inspector auto-approve / YOLO mode to connected agent
1439            if changes.ai_inspector_auto_approve
1440                && let Some(agent) = &window_state.agent
1441            {
1442                let agent = agent.clone();
1443                let auto_approve = config.ai_inspector_auto_approve;
1444                let mode = if auto_approve {
1445                    "bypassPermissions"
1446                } else {
1447                    "default"
1448                }
1449                .to_string();
1450                window_state.runtime.spawn(async move {
1451                    let agent = agent.lock().await;
1452                    agent
1453                        .auto_approve
1454                        .store(auto_approve, std::sync::atomic::Ordering::Relaxed);
1455                    if let Err(e) = agent.set_mode(&mode).await {
1456                        log::error!("ACP: failed to set mode '{mode}': {e}");
1457                    }
1458                });
1459            }
1460
1461            // Apply changes to renderer and collect any shader errors
1462            let (shader_result, cursor_result) = if let Some(renderer) = &mut window_state.renderer
1463            {
1464                // Update opacity
1465                renderer.update_opacity(config.window_opacity);
1466
1467                // Update transparency mode if changed
1468                if changes.transparency_mode {
1469                    renderer.set_transparency_affects_only_default_background(
1470                        config.transparency_affects_only_default_background,
1471                    );
1472                    window_state.needs_redraw = true;
1473                }
1474
1475                // Update text opacity mode if changed
1476                if changes.keep_text_opaque {
1477                    renderer.set_keep_text_opaque(config.keep_text_opaque);
1478                    window_state.needs_redraw = true;
1479                }
1480
1481                if changes.link_underline_style {
1482                    renderer.set_link_underline_style(config.link_underline_style);
1483                    window_state.needs_redraw = true;
1484                }
1485
1486                // Update vsync mode if changed
1487                if changes.vsync_mode {
1488                    let (actual_mode, _changed) = renderer.update_vsync_mode(config.vsync_mode);
1489                    // If the actual mode differs, update config
1490                    if actual_mode != config.vsync_mode {
1491                        window_state.config.vsync_mode = actual_mode;
1492                        log::warn!(
1493                            "Vsync mode {:?} is not supported. Using {:?} instead.",
1494                            config.vsync_mode,
1495                            actual_mode
1496                        );
1497                    }
1498                }
1499
1500                // Update scrollbar appearance
1501                renderer.update_scrollbar_appearance(
1502                    config.scrollbar_width,
1503                    config.scrollbar_thumb_color,
1504                    config.scrollbar_track_color,
1505                );
1506
1507                // Update cursor color
1508                if changes.cursor_color {
1509                    renderer.update_cursor_color(config.cursor_color);
1510                }
1511
1512                // Update cursor text color
1513                if changes.cursor_text_color {
1514                    renderer.update_cursor_text_color(config.cursor_text_color);
1515                }
1516
1517                // Update cursor style and blink for all tabs
1518                if changes.cursor_style || changes.cursor_blink {
1519                    use crate::config::CursorStyle as ConfigCursorStyle;
1520                    use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
1521
1522                    let term_style = if config.cursor_blink {
1523                        match config.cursor_style {
1524                            ConfigCursorStyle::Block => TermCursorStyle::BlinkingBlock,
1525                            ConfigCursorStyle::Beam => TermCursorStyle::BlinkingBar,
1526                            ConfigCursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
1527                        }
1528                    } else {
1529                        match config.cursor_style {
1530                            ConfigCursorStyle::Block => TermCursorStyle::SteadyBlock,
1531                            ConfigCursorStyle::Beam => TermCursorStyle::SteadyBar,
1532                            ConfigCursorStyle::Underline => TermCursorStyle::SteadyUnderline,
1533                        }
1534                    };
1535
1536                    for tab in window_state.tab_manager.tabs_mut() {
1537                        if let Ok(mut term) = tab.terminal.try_lock() {
1538                            term.set_cursor_style(term_style);
1539                        }
1540                        tab.cache.cells = None; // Invalidate cache to redraw cursor
1541                    }
1542                    window_state.needs_redraw = true;
1543                }
1544
1545                // Apply cursor enhancement changes
1546                if changes.cursor_enhancements {
1547                    renderer.update_cursor_guide(
1548                        config.cursor_guide_enabled,
1549                        config.cursor_guide_color,
1550                    );
1551                    renderer.update_cursor_shadow(
1552                        config.cursor_shadow_enabled,
1553                        config.cursor_shadow_color,
1554                        config.cursor_shadow_offset,
1555                        config.cursor_shadow_blur,
1556                    );
1557                    renderer.update_cursor_boost(config.cursor_boost, config.cursor_boost_color);
1558                    renderer.update_unfocused_cursor_style(config.unfocused_cursor_style);
1559                    window_state.needs_redraw = true;
1560                }
1561
1562                // Apply command separator changes
1563                if changes.command_separator {
1564                    renderer.update_command_separator(
1565                        config.command_separator_enabled,
1566                        config.command_separator_thickness,
1567                        config.command_separator_opacity,
1568                        config.command_separator_exit_color,
1569                        config.command_separator_color,
1570                    );
1571                    window_state.needs_redraw = true;
1572                }
1573
1574                // Apply background changes (mode, color, or image)
1575                if changes.any_bg_change() {
1576                    // Expand tilde in path
1577                    let expanded_path = config.background_image.as_ref().map(|p| {
1578                        if let Some(rest) = p.strip_prefix("~/")
1579                            && let Some(home) = dirs::home_dir()
1580                        {
1581                            return home.join(rest).to_string_lossy().to_string();
1582                        }
1583                        p.clone()
1584                    });
1585                    renderer.set_background(
1586                        config.background_mode,
1587                        config.background_color,
1588                        expanded_path.as_deref(),
1589                        config.background_image_mode,
1590                        config.background_image_opacity,
1591                        config.background_image_enabled,
1592                    );
1593                    window_state.needs_redraw = true;
1594                }
1595
1596                // Apply per-pane background changes to existing panes
1597                if changes.pane_backgrounds {
1598                    // Pre-load all pane background textures into the renderer cache
1599                    for pb_config in &config.pane_backgrounds {
1600                        if let Err(e) = renderer.load_pane_background(&pb_config.image) {
1601                            log::error!(
1602                                "Failed to load pane {} background '{}': {}",
1603                                pb_config.index,
1604                                pb_config.image,
1605                                e
1606                            );
1607                        }
1608                    }
1609
1610                    for tab in window_state.tab_manager.tabs_mut() {
1611                        if let Some(pm) = tab.pane_manager_mut() {
1612                            let panes = pm.all_panes_mut();
1613                            for (index, pane) in panes.into_iter().enumerate() {
1614                                if let Some((image_path, mode, opacity)) =
1615                                    config.get_pane_background(index)
1616                                {
1617                                    let bg = crate::pane::PaneBackground {
1618                                        image_path: Some(image_path),
1619                                        mode,
1620                                        opacity,
1621                                    };
1622                                    pane.set_background(bg);
1623                                } else {
1624                                    // Clear pane background if no longer configured
1625                                    pane.set_background(crate::pane::PaneBackground::new());
1626                                }
1627                            }
1628                        }
1629                    }
1630                    renderer.mark_dirty();
1631                    window_state.needs_redraw = true;
1632                }
1633
1634                // Apply inline image settings changes
1635                if changes.image_scaling_mode {
1636                    renderer.update_image_scaling_mode(config.image_scaling_mode);
1637                    window_state.needs_redraw = true;
1638                }
1639                if changes.image_preserve_aspect_ratio {
1640                    renderer.update_image_preserve_aspect_ratio(config.image_preserve_aspect_ratio);
1641                    window_state.needs_redraw = true;
1642                }
1643
1644                // Apply theme changes
1645                if changes.theme
1646                    && let Some(tab) = window_state.tab_manager.active_tab()
1647                    && let Ok(mut term) = tab.terminal.try_lock()
1648                {
1649                    term.set_theme(config.load_theme());
1650                }
1651
1652                // Update ENQ answerback string across all tabs when changed
1653                if changes.answerback_string {
1654                    let answerback = if config.answerback_string.is_empty() {
1655                        None
1656                    } else {
1657                        Some(config.answerback_string.clone())
1658                    };
1659                    for tab in window_state.tab_manager.tabs_mut() {
1660                        if let Ok(term) = tab.terminal.try_lock() {
1661                            term.set_answerback_string(answerback.clone());
1662                        }
1663                    }
1664                }
1665
1666                // Apply Unicode width settings
1667                if changes.unicode_width {
1668                    let width_config = par_term_emu_core_rust::WidthConfig::new(
1669                        config.unicode_version,
1670                        config.ambiguous_width,
1671                    );
1672                    for tab in window_state.tab_manager.tabs_mut() {
1673                        if let Ok(term) = tab.terminal.try_lock() {
1674                            term.set_width_config(width_config);
1675                        }
1676                    }
1677                }
1678
1679                // Apply Unicode normalization form
1680                if changes.normalization_form {
1681                    for tab in window_state.tab_manager.tabs_mut() {
1682                        if let Ok(term) = tab.terminal.try_lock() {
1683                            term.set_normalization_form(config.normalization_form);
1684                        }
1685                    }
1686                }
1687
1688                // Resolve per-shader settings (user override -> metadata defaults -> global)
1689                // This is computed once and used for both shader enable and background-as-channel0
1690                let shader_override = config
1691                    .custom_shader
1692                    .as_ref()
1693                    .and_then(|name| config.shader_configs.get(name));
1694                // Get shader metadata from cache for full 3-tier resolution
1695                let metadata = config
1696                    .custom_shader
1697                    .as_ref()
1698                    .and_then(|name| window_state.shader_metadata_cache.get(name).cloned());
1699                let resolved = resolve_shader_config(shader_override, metadata.as_ref(), config);
1700
1701                // Apply shader changes - track if change was attempted and result
1702                // Option<Option<String>>: None = no change attempted, Some(None) = success, Some(Some(err)) = error
1703                let shader_result =
1704                    if changes.any_shader_change() || changes.shader_per_shader_config {
1705                        log::info!(
1706                            "SETTINGS: applying shader change: {:?} -> {:?}",
1707                            window_state.config.custom_shader,
1708                            config.custom_shader
1709                        );
1710                        Some(
1711                            renderer
1712                                .set_custom_shader_enabled(
1713                                    config.custom_shader_enabled,
1714                                    config.custom_shader.as_deref(),
1715                                    config.window_opacity,
1716                                    config.custom_shader_animation,
1717                                    resolved.animation_speed,
1718                                    resolved.full_content,
1719                                    resolved.brightness,
1720                                    &resolved.channel_paths(),
1721                                    resolved.cubemap_path().map(|p| p.as_path()),
1722                                )
1723                                .err(),
1724                        )
1725                    } else {
1726                        None // No change attempted
1727                    };
1728
1729                // Apply use_background_as_channel0 setting
1730                // This needs to be applied after the shader is loaded but before it renders
1731                // Include any_shader_change() to ensure the setting is applied when a new shader is loaded
1732                if changes.any_shader_change()
1733                    || changes.shader_use_background_as_channel0
1734                    || changes.any_bg_change()
1735                    || changes.shader_per_shader_config
1736                {
1737                    renderer.update_background_as_channel0_with_mode(
1738                        resolved.use_background_as_channel0,
1739                        config.background_mode,
1740                        config.background_color,
1741                    );
1742                }
1743
1744                // Apply cursor shader changes
1745                let cursor_result = if changes.any_cursor_shader_toggle() {
1746                    Some(
1747                        renderer
1748                            .set_cursor_shader_enabled(
1749                                config.cursor_shader_enabled,
1750                                config.cursor_shader.as_deref(),
1751                                config.window_opacity,
1752                                config.cursor_shader_animation,
1753                                config.cursor_shader_animation_speed,
1754                            )
1755                            .err(),
1756                    )
1757                } else {
1758                    None // No change attempted
1759                };
1760
1761                (shader_result, cursor_result)
1762            } else {
1763                (None, None)
1764            };
1765
1766            // Track shader errors for propagation to standalone settings window
1767            // shader_result: None = no change attempted, Some(None) = success, Some(Some(err)) = error
1768            if let Some(result) = shader_result {
1769                last_shader_result = Some(result);
1770            }
1771            if let Some(result) = cursor_result {
1772                last_cursor_shader_result = Some(result);
1773            }
1774
1775            // Apply font rendering changes that can update live
1776            if changes.font_rendering {
1777                if let Some(renderer) = &mut window_state.renderer {
1778                    let mut updated = false;
1779                    updated |= renderer.update_font_antialias(config.font_antialias);
1780                    updated |= renderer.update_font_hinting(config.font_hinting);
1781                    updated |= renderer.update_font_thin_strokes(config.font_thin_strokes);
1782                    updated |= renderer.update_minimum_contrast(config.minimum_contrast);
1783                    if updated {
1784                        window_state.needs_redraw = true;
1785                    }
1786                } else {
1787                    window_state.pending_font_rebuild = true;
1788                }
1789            }
1790
1791            // Apply window-related changes
1792            if let Some(window) = &window_state.window {
1793                // Update window title (handles both title change and show_window_number toggle)
1794                // Note: config is already updated at this point (line 985)
1795                if changes.window_title || changes.show_window_number {
1796                    let title = window_state.format_title(&window_state.config.window_title);
1797                    window.set_title(&title);
1798                }
1799                if changes.window_decorations {
1800                    window.set_decorations(config.window_decorations);
1801                }
1802                if changes.lock_window_size {
1803                    window.set_resizable(!config.lock_window_size);
1804                    log::info!("Window resizable set to: {}", !config.lock_window_size);
1805                }
1806                window.set_window_level(if config.window_always_on_top {
1807                    winit::window::WindowLevel::AlwaysOnTop
1808                } else {
1809                    winit::window::WindowLevel::Normal
1810                });
1811
1812                // Apply blur changes (macOS only)
1813                #[cfg(target_os = "macos")]
1814                if changes.blur {
1815                    let blur_radius = if config.blur_enabled && config.window_opacity < 1.0 {
1816                        config.blur_radius
1817                    } else {
1818                        0 // Disable blur when not enabled or fully opaque
1819                    };
1820                    if let Err(e) = crate::macos_blur::set_window_blur(window, blur_radius) {
1821                        log::warn!("Failed to set window blur: {}", e);
1822                    }
1823                }
1824
1825                window.request_redraw();
1826            }
1827
1828            // Queue font rebuild if needed
1829            if changes.font {
1830                window_state.pending_font_rebuild = true;
1831            }
1832
1833            // Reinitialize shader watcher if shader paths changed
1834            if changes.needs_watcher_reinit() {
1835                window_state.reinit_shader_watcher();
1836            }
1837
1838            // Restart refresh tasks when max_fps changes
1839            if changes.max_fps
1840                && let Some(window) = &window_state.window
1841            {
1842                for tab in window_state.tab_manager.tabs_mut() {
1843                    tab.stop_refresh_task();
1844                    tab.start_refresh_task(
1845                        Arc::clone(&window_state.runtime),
1846                        Arc::clone(window),
1847                        config.max_fps,
1848                    );
1849                }
1850                log::info!("Restarted refresh tasks with max_fps={}", config.max_fps);
1851            }
1852
1853            // Update badge state if badge settings changed
1854            if changes.badge {
1855                window_state.badge_state.update_config(config);
1856                window_state.badge_state.mark_dirty();
1857            }
1858
1859            // Sync status bar monitor state after config changes
1860            window_state.status_bar_ui.sync_monitor_state(config);
1861
1862            // Update pane divider settings on all tabs with pane managers
1863            // Scale from logical pixels (config) to physical pixels for layout calculations
1864            let dpi_scale = window_state
1865                .renderer
1866                .as_ref()
1867                .map(|r| r.scale_factor())
1868                .unwrap_or(1.0);
1869            let divider_width = config.pane_divider_width.unwrap_or(2.0) * dpi_scale;
1870            for tab in window_state.tab_manager.tabs_mut() {
1871                if let Some(pm) = tab.pane_manager_mut() {
1872                    pm.set_divider_width(divider_width);
1873                    pm.set_divider_hit_width(config.pane_divider_hit_width * dpi_scale);
1874                }
1875            }
1876
1877            // Resync triggers from config into core registry for all tabs
1878            for tab in window_state.tab_manager.tabs() {
1879                if let Ok(term) = tab.terminal.try_lock() {
1880                    term.sync_triggers(&config.triggers);
1881                }
1882            }
1883
1884            // Invalidate cache
1885            if let Some(tab) = window_state.tab_manager.active_tab_mut() {
1886                tab.cache.cells = None;
1887            }
1888            window_state.needs_redraw = true;
1889        }
1890
1891        // Restart dynamic profile manager if sources changed
1892        let dynamic_sources_changed =
1893            self.config.dynamic_profile_sources != config.dynamic_profile_sources;
1894
1895        // Also update the shared config
1896        self.config = config.clone();
1897
1898        // Restart dynamic profile manager with new sources if they changed
1899        if dynamic_sources_changed {
1900            self.dynamic_profile_manager.stop();
1901            if !config.dynamic_profile_sources.is_empty() {
1902                self.dynamic_profile_manager
1903                    .start(&config.dynamic_profile_sources, &self.runtime);
1904            }
1905            log::info!(
1906                "Dynamic profile manager restarted with {} sources",
1907                config.dynamic_profile_sources.len()
1908            );
1909        }
1910
1911        // Update standalone settings window with shader errors only when a change was attempted
1912        if let Some(settings_window) = &mut self.settings_window {
1913            if let Some(result) = last_shader_result {
1914                settings_window.set_shader_error(result);
1915            }
1916            if let Some(result) = last_cursor_shader_result {
1917                settings_window.set_cursor_shader_error(result);
1918            }
1919        }
1920    }
1921
1922    /// Apply shader changes from settings window editor
1923    pub fn apply_shader_from_editor(&mut self, source: &str) -> Result<(), String> {
1924        let mut last_error = None;
1925
1926        for window_state in self.windows.values_mut() {
1927            if let Some(renderer) = &mut window_state.renderer {
1928                match renderer.reload_shader_from_source(source) {
1929                    Ok(()) => {
1930                        window_state.needs_redraw = true;
1931                        if let Some(window) = &window_state.window {
1932                            window.request_redraw();
1933                        }
1934                    }
1935                    Err(e) => {
1936                        last_error = Some(format!("{:#}", e));
1937                    }
1938                }
1939            }
1940        }
1941
1942        // Update settings window with error status
1943        if let Some(settings_window) = &mut self.settings_window {
1944            if let Some(ref err) = last_error {
1945                settings_window.set_shader_error(Some(err.clone()));
1946            } else {
1947                settings_window.clear_shader_error();
1948            }
1949        }
1950
1951        last_error.map_or(Ok(()), Err)
1952    }
1953
1954    /// Apply cursor shader changes from settings window editor
1955    pub fn apply_cursor_shader_from_editor(&mut self, source: &str) -> Result<(), String> {
1956        let mut last_error = None;
1957
1958        for window_state in self.windows.values_mut() {
1959            if let Some(renderer) = &mut window_state.renderer {
1960                match renderer.reload_cursor_shader_from_source(source) {
1961                    Ok(()) => {
1962                        window_state.needs_redraw = true;
1963                        if let Some(window) = &window_state.window {
1964                            window.request_redraw();
1965                        }
1966                    }
1967                    Err(e) => {
1968                        last_error = Some(format!("{:#}", e));
1969                    }
1970                }
1971            }
1972        }
1973
1974        // Update settings window with error status
1975        if let Some(settings_window) = &mut self.settings_window {
1976            if let Some(ref err) = last_error {
1977                settings_window.set_cursor_shader_error(Some(err.clone()));
1978            } else {
1979                settings_window.clear_cursor_shader_error();
1980            }
1981        }
1982
1983        last_error.map_or(Ok(()), Err)
1984    }
1985
1986    /// Request redraw for settings window
1987    pub fn request_settings_redraw(&self) {
1988        if let Some(settings_window) = &self.settings_window {
1989            settings_window.request_redraw();
1990        }
1991    }
1992
1993    /// Start a coprocess by config index on the focused window's active tab.
1994    pub fn start_coprocess(&mut self, config_index: usize) {
1995        log::debug!("start_coprocess called with index {}", config_index);
1996        let focused = self.get_focused_window_id();
1997        if let Some(window_id) = focused
1998            && let Some(ws) = self.windows.get_mut(&window_id)
1999            && let Some(tab) = ws.tab_manager.active_tab_mut()
2000        {
2001            if config_index >= ws.config.coprocesses.len() {
2002                log::warn!("Coprocess config index {} out of range", config_index);
2003                return;
2004            }
2005            let coproc_config = &ws.config.coprocesses[config_index];
2006            let core_config = par_term_emu_core_rust::coprocess::CoprocessConfig {
2007                command: coproc_config.command.clone(),
2008                args: coproc_config.args.clone(),
2009                cwd: None,
2010                env: crate::terminal::coprocess_env(),
2011                copy_terminal_output: coproc_config.copy_terminal_output,
2012                restart_policy: coproc_config.restart_policy.to_core(),
2013                restart_delay_ms: coproc_config.restart_delay_ms,
2014            };
2015            // Use blocking_lock since this is an infrequent user-initiated operation
2016            let term = tab.terminal.blocking_lock();
2017            match term.start_coprocess(core_config) {
2018                Ok(id) => {
2019                    log::info!("Started coprocess '{}' (id={})", coproc_config.name, id);
2020                    // Ensure coprocess_ids vec is large enough
2021                    while tab.coprocess_ids.len() <= config_index {
2022                        tab.coprocess_ids.push(None);
2023                    }
2024                    tab.coprocess_ids[config_index] = Some(id);
2025                }
2026                Err(e) => {
2027                    let err_msg = format!("Failed to start: {}", e);
2028                    log::error!("Failed to start coprocess '{}': {}", coproc_config.name, e);
2029                    // Show error in settings UI
2030                    if let Some(sw) = &mut self.settings_window {
2031                        let errors = &mut sw.settings_ui.coprocess_errors;
2032                        while errors.len() <= config_index {
2033                            errors.push(String::new());
2034                        }
2035                        errors[config_index] = err_msg;
2036                        sw.request_redraw();
2037                    }
2038                    return;
2039                }
2040            }
2041            drop(term);
2042            // Update running state in settings window
2043            self.sync_coprocess_running_state();
2044        } else {
2045            log::warn!("start_coprocess: no focused window or active tab found");
2046        }
2047    }
2048
2049    /// Stop a coprocess by config index on the focused window's active tab.
2050    pub fn stop_coprocess(&mut self, config_index: usize) {
2051        log::debug!("stop_coprocess called with index {}", config_index);
2052        let focused = self.get_focused_window_id();
2053        if let Some(window_id) = focused
2054            && let Some(ws) = self.windows.get_mut(&window_id)
2055            && let Some(tab) = ws.tab_manager.active_tab_mut()
2056        {
2057            if let Some(Some(id)) = tab.coprocess_ids.get(config_index).copied() {
2058                // Use blocking_lock since this is an infrequent user-initiated operation
2059                let term = tab.terminal.blocking_lock();
2060                if let Err(e) = term.stop_coprocess(id) {
2061                    log::error!("Failed to stop coprocess at index {}: {}", config_index, e);
2062                } else {
2063                    log::info!("Stopped coprocess at index {} (id={})", config_index, id);
2064                }
2065                drop(term);
2066                tab.coprocess_ids[config_index] = None;
2067            }
2068            // Update running state in settings window
2069            self.sync_coprocess_running_state();
2070        }
2071    }
2072
2073    /// Maximum number of output lines kept per coprocess in the UI.
2074    const COPROCESS_OUTPUT_MAX_LINES: usize = 200;
2075
2076    /// Sync coprocess running state to the settings window.
2077    pub fn sync_coprocess_running_state(&mut self) {
2078        let focused = self.get_focused_window_id();
2079        let (running_state, error_state, new_output): (Vec<bool>, Vec<String>, Vec<Vec<String>>) =
2080            if let Some(window_id) = focused
2081                && let Some(ws) = self.windows.get(&window_id)
2082                && let Some(tab) = ws.tab_manager.active_tab()
2083            {
2084                if let Ok(term) = tab.terminal.try_lock() {
2085                    let mut running = Vec::new();
2086                    let mut errors = Vec::new();
2087                    let mut output = Vec::new();
2088                    for (i, _) in ws.config.coprocesses.iter().enumerate() {
2089                        let has_id = tab.coprocess_ids.get(i).and_then(|opt| opt.as_ref());
2090                        let is_running =
2091                            has_id.is_some_and(|id| term.coprocess_status(*id).unwrap_or(false));
2092                        // If coprocess has an id but is not running, check stderr.
2093                        // If no id (never started or start failed), preserve existing error.
2094                        let err_text = if let Some(id) = has_id {
2095                            if is_running {
2096                                String::new()
2097                            } else {
2098                                term.read_coprocess_errors(*id)
2099                                    .unwrap_or_default()
2100                                    .join("\n")
2101                            }
2102                        } else if let Some(sw) = &self.settings_window
2103                            && let Some(existing) = sw.settings_ui.coprocess_errors.get(i)
2104                            && !existing.is_empty()
2105                        {
2106                            existing.clone()
2107                        } else {
2108                            String::new()
2109                        };
2110                        // Drain stdout buffer from the core
2111                        let lines = if let Some(id) = has_id {
2112                            term.read_from_coprocess(*id).unwrap_or_default()
2113                        } else {
2114                            Vec::new()
2115                        };
2116                        running.push(is_running);
2117                        errors.push(err_text);
2118                        output.push(lines);
2119                    }
2120                    (running, errors, output)
2121                } else {
2122                    (Vec::new(), Vec::new(), Vec::new())
2123                }
2124            } else {
2125                (Vec::new(), Vec::new(), Vec::new())
2126            };
2127        if let Some(sw) = &mut self.settings_window {
2128            let running_changed = sw.settings_ui.coprocess_running != running_state;
2129            let errors_changed = sw.settings_ui.coprocess_errors != error_state;
2130            let has_new_output = new_output.iter().any(|lines| !lines.is_empty());
2131
2132            // Ensure output/expanded vecs are the right size
2133            let count = running_state.len();
2134            sw.settings_ui.coprocess_output.resize_with(count, Vec::new);
2135            sw.settings_ui
2136                .coprocess_output_expanded
2137                .resize(count, false);
2138
2139            // Append new output lines, capping at max
2140            for (i, lines) in new_output.into_iter().enumerate() {
2141                if !lines.is_empty() {
2142                    let buf = &mut sw.settings_ui.coprocess_output[i];
2143                    buf.extend(lines);
2144                    let overflow = buf.len().saturating_sub(Self::COPROCESS_OUTPUT_MAX_LINES);
2145                    if overflow > 0 {
2146                        buf.drain(..overflow);
2147                    }
2148                }
2149            }
2150
2151            if running_changed || errors_changed || has_new_output {
2152                sw.settings_ui.coprocess_running = running_state;
2153                sw.settings_ui.coprocess_errors = error_state;
2154                sw.request_redraw();
2155            }
2156        }
2157    }
2158
2159    // ========================================================================
2160    // Script Methods
2161    // ========================================================================
2162
2163    /// Start a script by config index on the focused window's active tab.
2164    pub fn start_script(&mut self, config_index: usize) {
2165        crate::debug_info!(
2166            "SCRIPT",
2167            "start_script called with config_index={}",
2168            config_index
2169        );
2170        let focused = self.get_focused_window_id();
2171        if let Some(window_id) = focused
2172            && let Some(ws) = self.windows.get_mut(&window_id)
2173            && let Some(tab) = ws.tab_manager.active_tab_mut()
2174        {
2175            crate::debug_info!(
2176                "SCRIPT",
2177                "start_script: ws.config.scripts.len()={}, tab.script_ids.len()={}",
2178                ws.config.scripts.len(),
2179                tab.script_ids.len()
2180            );
2181            if config_index >= ws.config.scripts.len() {
2182                crate::debug_error!(
2183                    "SCRIPT",
2184                    "Script config index {} out of range (scripts.len={})",
2185                    config_index,
2186                    ws.config.scripts.len()
2187                );
2188                return;
2189            }
2190            let script_config = &ws.config.scripts[config_index];
2191            crate::debug_info!(
2192                "SCRIPT",
2193                "start_script: found config name='{}' path='{}' enabled={} args={:?}",
2194                script_config.name,
2195                script_config.script_path,
2196                script_config.enabled,
2197                script_config.args
2198            );
2199            if !script_config.enabled {
2200                crate::debug_info!(
2201                    "SCRIPT",
2202                    "Script '{}' is disabled, not starting",
2203                    script_config.name
2204                );
2205                return;
2206            }
2207
2208            // Build subscription filter from config
2209            let subscription_filter = if script_config.subscriptions.is_empty() {
2210                None
2211            } else {
2212                Some(
2213                    script_config
2214                        .subscriptions
2215                        .iter()
2216                        .cloned()
2217                        .collect::<std::collections::HashSet<String>>(),
2218                )
2219            };
2220
2221            // Create the event forwarder and register it as an observer
2222            let forwarder = std::sync::Arc::new(
2223                crate::scripting::observer::ScriptEventForwarder::new(subscription_filter),
2224            );
2225
2226            // Register observer with terminal (user-initiated, use blocking_lock)
2227            let observer_id = {
2228                let term = tab.terminal.blocking_lock();
2229                term.add_observer(forwarder.clone())
2230            };
2231
2232            // Start the script process
2233            crate::debug_info!("SCRIPT", "start_script: spawning process...");
2234            match tab.script_manager.start_script(script_config) {
2235                Ok(script_id) => {
2236                    crate::debug_info!(
2237                        "SCRIPT",
2238                        "start_script: SUCCESS script_id={} observer_id={:?}",
2239                        script_id,
2240                        observer_id
2241                    );
2242
2243                    // Ensure vecs are large enough
2244                    while tab.script_ids.len() <= config_index {
2245                        tab.script_ids.push(None);
2246                    }
2247                    while tab.script_observer_ids.len() <= config_index {
2248                        tab.script_observer_ids.push(None);
2249                    }
2250                    while tab.script_forwarders.len() <= config_index {
2251                        tab.script_forwarders.push(None);
2252                    }
2253
2254                    tab.script_ids[config_index] = Some(script_id);
2255                    tab.script_observer_ids[config_index] = Some(observer_id);
2256                    tab.script_forwarders[config_index] = Some(forwarder);
2257                }
2258                Err(e) => {
2259                    let err_msg = format!("Failed to start: {}", e);
2260                    crate::debug_error!(
2261                        "SCRIPT",
2262                        "start_script: FAILED to start '{}': {}",
2263                        script_config.name,
2264                        e
2265                    );
2266
2267                    // Remove observer since script failed to start
2268                    let term = tab.terminal.blocking_lock();
2269                    term.remove_observer(observer_id);
2270                    drop(term);
2271
2272                    // Show error in settings UI
2273                    if let Some(sw) = &mut self.settings_window {
2274                        let errors = &mut sw.settings_ui.script_errors;
2275                        while errors.len() <= config_index {
2276                            errors.push(String::new());
2277                        }
2278                        errors[config_index] = err_msg;
2279                        sw.request_redraw();
2280                    }
2281                    return;
2282                }
2283            }
2284            // Update running state in settings window
2285            self.sync_script_running_state();
2286        } else {
2287            crate::debug_error!(
2288                "SCRIPT",
2289                "start_script: no focused window or active tab found"
2290            );
2291        }
2292    }
2293
2294    /// Stop a script by config index on the focused window's active tab.
2295    pub fn stop_script(&mut self, config_index: usize) {
2296        log::debug!("stop_script called with index {}", config_index);
2297        let focused = self.get_focused_window_id();
2298        if let Some(window_id) = focused
2299            && let Some(ws) = self.windows.get_mut(&window_id)
2300            && let Some(tab) = ws.tab_manager.active_tab_mut()
2301        {
2302            // Stop the script process
2303            if let Some(Some(script_id)) = tab.script_ids.get(config_index).copied() {
2304                tab.script_manager.stop_script(script_id);
2305                log::info!(
2306                    "Stopped script at index {} (id={})",
2307                    config_index,
2308                    script_id
2309                );
2310            }
2311
2312            // Remove observer from terminal
2313            if let Some(Some(observer_id)) = tab.script_observer_ids.get(config_index).copied() {
2314                let term = tab.terminal.blocking_lock();
2315                term.remove_observer(observer_id);
2316                drop(term);
2317            }
2318
2319            // Clear tracking state
2320            if let Some(slot) = tab.script_ids.get_mut(config_index) {
2321                *slot = None;
2322            }
2323            if let Some(slot) = tab.script_observer_ids.get_mut(config_index) {
2324                *slot = None;
2325            }
2326            if let Some(slot) = tab.script_forwarders.get_mut(config_index) {
2327                *slot = None;
2328            }
2329
2330            // Update running state in settings window
2331            self.sync_script_running_state();
2332        }
2333    }
2334
2335    /// Maximum number of output lines kept per script in the UI.
2336    const SCRIPT_OUTPUT_MAX_LINES: usize = 200;
2337
2338    /// Sync script running state to the settings window.
2339    ///
2340    /// Drains events from forwarders, sends them to scripts, reads commands
2341    /// and errors back, and updates the settings UI state.
2342    pub fn sync_script_running_state(&mut self) {
2343        let focused = self.get_focused_window_id();
2344
2345        // Collect state from the active tab
2346        #[allow(clippy::type_complexity)]
2347        let (running_state, error_state, new_output, panel_state): (
2348            Vec<bool>,
2349            Vec<String>,
2350            Vec<Vec<String>>,
2351            Vec<Option<(String, String)>>,
2352        ) = if let Some(window_id) = focused
2353            && let Some(ws) = self.windows.get_mut(&window_id)
2354            && let Some(tab) = ws.tab_manager.active_tab_mut()
2355        {
2356            let script_count = ws.config.scripts.len();
2357            let mut running = Vec::with_capacity(script_count);
2358            let mut errors = Vec::with_capacity(script_count);
2359            let mut output = Vec::with_capacity(script_count);
2360            let mut panels = Vec::with_capacity(script_count);
2361
2362            for i in 0..script_count {
2363                let has_script_id = tab.script_ids.get(i).and_then(|opt| *opt);
2364                let is_running = has_script_id.is_some_and(|id| tab.script_manager.is_running(id));
2365
2366                // Drain events from forwarder and send to script
2367                if is_running && let Some(Some(forwarder)) = tab.script_forwarders.get(i) {
2368                    let events = forwarder.drain_events();
2369                    if let Some(script_id) = has_script_id {
2370                        for event in &events {
2371                            let _ = tab.script_manager.send_event(script_id, event);
2372                        }
2373                    }
2374                }
2375
2376                // Read commands from script and process them
2377                let mut log_lines = Vec::new();
2378                let mut panel_val = tab
2379                    .script_manager
2380                    .get_panel(has_script_id.unwrap_or(0))
2381                    .cloned();
2382
2383                if let Some(script_id) = has_script_id {
2384                    let commands = tab.script_manager.read_commands(script_id);
2385                    for cmd in commands {
2386                        match cmd {
2387                            crate::scripting::protocol::ScriptCommand::Log { level, message } => {
2388                                log_lines.push(format!("[{}] {}", level, message));
2389                            }
2390                            crate::scripting::protocol::ScriptCommand::SetPanel {
2391                                title,
2392                                content,
2393                            } => {
2394                                tab.script_manager.set_panel(
2395                                    script_id,
2396                                    title.clone(),
2397                                    content.clone(),
2398                                );
2399                                panel_val = Some((title, content));
2400                            }
2401                            crate::scripting::protocol::ScriptCommand::ClearPanel {} => {
2402                                tab.script_manager.clear_panel(script_id);
2403                                panel_val = None;
2404                            }
2405                            // TODO: Implement WriteText, Notify, SetBadge, SetVariable,
2406                            // RunCommand, ChangeConfig — these require proper access to the
2407                            // terminal and config systems. Will be completed after the basic
2408                            // infrastructure is working.
2409                            _ => {
2410                                log::debug!("Script command not yet implemented: {:?}", cmd);
2411                            }
2412                        }
2413                    }
2414                }
2415
2416                // Read errors from script
2417                let err_text = if let Some(script_id) = has_script_id {
2418                    if is_running {
2419                        // Drain any stderr lines even while running
2420                        let err_lines = tab.script_manager.read_errors(script_id);
2421                        if !err_lines.is_empty() {
2422                            err_lines.join("\n")
2423                        } else {
2424                            String::new()
2425                        }
2426                    } else {
2427                        let err_lines = tab.script_manager.read_errors(script_id);
2428                        err_lines.join("\n")
2429                    }
2430                } else if let Some(sw) = &self.settings_window
2431                    && let Some(existing) = sw.settings_ui.script_errors.get(i)
2432                    && !existing.is_empty()
2433                {
2434                    existing.clone()
2435                } else {
2436                    String::new()
2437                };
2438
2439                running.push(is_running);
2440                errors.push(err_text);
2441                output.push(log_lines);
2442                panels.push(panel_val);
2443            }
2444
2445            (running, errors, output, panels)
2446        } else {
2447            (Vec::new(), Vec::new(), Vec::new(), Vec::new())
2448        };
2449
2450        // Update settings window state
2451        if let Some(sw) = &mut self.settings_window {
2452            let running_changed = sw.settings_ui.script_running != running_state;
2453            let errors_changed = sw.settings_ui.script_errors != error_state;
2454            let has_new_output = new_output.iter().any(|lines| !lines.is_empty());
2455            let panels_changed = sw.settings_ui.script_panels != panel_state;
2456
2457            if running_changed || errors_changed {
2458                crate::debug_info!(
2459                    "SCRIPT",
2460                    "sync: state change - running={:?} errors_changed={}",
2461                    running_state,
2462                    errors_changed
2463                );
2464            }
2465
2466            let count = running_state.len();
2467            sw.settings_ui.script_output.resize_with(count, Vec::new);
2468            sw.settings_ui.script_output_expanded.resize(count, false);
2469            sw.settings_ui.script_panels.resize_with(count, || None);
2470
2471            // Append new output lines, capping at max
2472            for (i, lines) in new_output.into_iter().enumerate() {
2473                if !lines.is_empty() {
2474                    let buf = &mut sw.settings_ui.script_output[i];
2475                    buf.extend(lines);
2476                    let overflow = buf.len().saturating_sub(Self::SCRIPT_OUTPUT_MAX_LINES);
2477                    if overflow > 0 {
2478                        buf.drain(..overflow);
2479                    }
2480                }
2481            }
2482
2483            if running_changed || errors_changed || has_new_output || panels_changed {
2484                sw.settings_ui.script_running = running_state;
2485                sw.settings_ui.script_errors = error_state;
2486                sw.settings_ui.script_panels = panel_state;
2487                sw.request_redraw();
2488            }
2489        }
2490    }
2491
2492    // ========================================================================
2493    // Window Arrangement Methods
2494    // ========================================================================
2495
2496    /// Create a new window with specific position and size overrides.
2497    ///
2498    /// Unlike `create_window()`, this skips `apply_window_positioning()` and
2499    /// places the window at the exact specified position and size.
2500    /// Additional tabs (beyond the first) are created with the given CWDs.
2501    pub fn create_window_with_overrides(
2502        &mut self,
2503        event_loop: &ActiveEventLoop,
2504        position: (i32, i32),
2505        size: (u32, u32),
2506        tab_cwds: &[Option<String>],
2507        active_tab_index: usize,
2508    ) {
2509        use winit::window::Window;
2510
2511        // Reload config from disk to pick up any changes
2512        if let Ok(fresh_config) = Config::load() {
2513            self.config = fresh_config;
2514        }
2515
2516        // Build window title
2517        let window_number = self.windows.len() + 1;
2518        let title = if self.config.show_window_number {
2519            format!("{} [{}]", self.config.window_title, window_number)
2520        } else {
2521            self.config.window_title.clone()
2522        };
2523
2524        let mut window_attrs = Window::default_attributes()
2525            .with_title(&title)
2526            .with_inner_size(winit::dpi::PhysicalSize::new(size.0, size.1))
2527            .with_position(winit::dpi::PhysicalPosition::new(position.0, position.1))
2528            .with_decorations(self.config.window_decorations);
2529
2530        if self.config.lock_window_size {
2531            window_attrs = window_attrs.with_resizable(false);
2532        }
2533
2534        // Load and set the application icon
2535        let icon_bytes = include_bytes!("../../assets/icon.png");
2536        if let Ok(icon_image) = image::load_from_memory(icon_bytes) {
2537            let rgba = icon_image.to_rgba8();
2538            let (w, h) = rgba.dimensions();
2539            if let Ok(icon) = winit::window::Icon::from_rgba(rgba.into_raw(), w, h) {
2540                window_attrs = window_attrs.with_window_icon(Some(icon));
2541            }
2542        }
2543
2544        if self.config.window_always_on_top {
2545            window_attrs = window_attrs.with_window_level(winit::window::WindowLevel::AlwaysOnTop);
2546        }
2547
2548        window_attrs = window_attrs.with_transparent(true);
2549
2550        match event_loop.create_window(window_attrs) {
2551            Ok(window) => {
2552                let window_id = window.id();
2553                let mut window_state =
2554                    WindowState::new(self.config.clone(), Arc::clone(&self.runtime));
2555                window_state.window_index = window_number;
2556
2557                let runtime = Arc::clone(&self.runtime);
2558                if let Err(e) = runtime.block_on(window_state.initialize_async(window)) {
2559                    log::error!("Failed to initialize arranged window: {}", e);
2560                    return;
2561                }
2562
2563                // Initialize menu for first window or attach to additional
2564                if self.menu.is_none() {
2565                    match MenuManager::new() {
2566                        Ok(menu) => {
2567                            if let Some(win) = &window_state.window
2568                                && let Err(e) = menu.init_for_window(win)
2569                            {
2570                                log::warn!("Failed to initialize menu: {}", e);
2571                            }
2572                            self.menu = Some(menu);
2573                        }
2574                        Err(e) => {
2575                            log::warn!("Failed to create menu: {}", e);
2576                        }
2577                    }
2578                } else if let Some(menu) = &self.menu
2579                    && let Some(win) = &window_state.window
2580                    && let Err(e) = menu.init_for_window(win)
2581                {
2582                    log::warn!("Failed to initialize menu for window: {}", e);
2583                }
2584
2585                // Set the position explicitly (in case the WM overrode it)
2586                if let Some(win) = &window_state.window {
2587                    win.set_outer_position(winit::dpi::PhysicalPosition::new(
2588                        position.0, position.1,
2589                    ));
2590                }
2591
2592                // Create additional tabs with specific CWDs
2593                // First tab is already created by WindowState::new, so set its CWD
2594                if let Some(first_cwd) = tab_cwds.first().and_then(|c| c.as_ref())
2595                    && let Some(tab) = window_state.tab_manager.active_tab_mut()
2596                {
2597                    tab.working_directory = Some(first_cwd.clone());
2598                }
2599
2600                // Create remaining tabs
2601                let grid_size = window_state.renderer.as_ref().map(|r| r.grid_size());
2602                for cwd in tab_cwds.iter().skip(1) {
2603                    if let Err(e) = window_state.tab_manager.new_tab_with_cwd(
2604                        &self.config,
2605                        Arc::clone(&self.runtime),
2606                        cwd.clone(),
2607                        grid_size,
2608                    ) {
2609                        log::warn!("Failed to create tab in arranged window: {}", e);
2610                    }
2611                }
2612
2613                // Switch to the saved active tab (switch_to_index is 1-based)
2614                window_state
2615                    .tab_manager
2616                    .switch_to_index(active_tab_index + 1);
2617
2618                // Start refresh tasks for all tabs
2619                if let Some(win) = &window_state.window {
2620                    for tab in window_state.tab_manager.tabs_mut() {
2621                        tab.start_refresh_task(
2622                            Arc::clone(&self.runtime),
2623                            Arc::clone(win),
2624                            self.config.max_fps,
2625                        );
2626                    }
2627                }
2628
2629                self.windows.insert(window_id, window_state);
2630                self.pending_window_count += 1;
2631
2632                if self.start_time.is_none() {
2633                    self.start_time = Some(Instant::now());
2634                }
2635
2636                log::info!(
2637                    "Created arranged window {:?} at ({}, {}) size {}x{} with {} tabs",
2638                    window_id,
2639                    position.0,
2640                    position.1,
2641                    size.0,
2642                    size.1,
2643                    tab_cwds.len().max(1),
2644                );
2645            }
2646            Err(e) => {
2647                log::error!("Failed to create arranged window: {}", e);
2648            }
2649        }
2650    }
2651
2652    /// Save the current window layout as an arrangement
2653    pub fn save_arrangement(&mut self, name: String, event_loop: &ActiveEventLoop) {
2654        // Remove existing arrangement with the same name (case-insensitive) to allow overwrite
2655        if let Some(existing) = self.arrangement_manager.find_by_name(&name) {
2656            let existing_id = existing.id;
2657            self.arrangement_manager.remove(&existing_id);
2658            log::info!("Overwriting existing arrangement '{}'", name);
2659        }
2660
2661        let arrangement =
2662            arrangements::capture::capture_arrangement(name.clone(), &self.windows, event_loop);
2663        log::info!(
2664            "Saved arrangement '{}' with {} windows",
2665            name,
2666            arrangement.windows.len()
2667        );
2668        self.arrangement_manager.add(arrangement);
2669        if let Err(e) = arrangements::storage::save_arrangements(&self.arrangement_manager) {
2670            log::error!("Failed to save arrangements: {}", e);
2671        }
2672        self.sync_arrangements_to_settings();
2673    }
2674
2675    /// Restore a saved arrangement by ID.
2676    ///
2677    /// Closes all existing windows and creates new ones according to the arrangement.
2678    pub fn restore_arrangement(&mut self, id: ArrangementId, event_loop: &ActiveEventLoop) {
2679        let arrangement = match self.arrangement_manager.get(&id) {
2680            Some(a) => a.clone(),
2681            None => {
2682                log::error!("Arrangement not found: {}", id);
2683                return;
2684            }
2685        };
2686
2687        log::info!(
2688            "Restoring arrangement '{}' ({} windows)",
2689            arrangement.name,
2690            arrangement.windows.len()
2691        );
2692
2693        // Close all existing windows
2694        let window_ids: Vec<WindowId> = self.windows.keys().copied().collect();
2695        for window_id in window_ids {
2696            if let Some(window_state) = self.windows.remove(&window_id) {
2697                drop(window_state);
2698            }
2699        }
2700
2701        // Build monitor mapping
2702        let available_monitors: Vec<_> = event_loop.available_monitors().collect();
2703        let monitor_mapping = arrangements::restore::build_monitor_mapping(
2704            &arrangement.monitor_layout,
2705            &available_monitors,
2706        );
2707
2708        // Create windows from arrangement
2709        for (i, window_snapshot) in arrangement.windows.iter().enumerate() {
2710            let Some((x, y, w, h)) = arrangements::restore::compute_restore_position(
2711                window_snapshot,
2712                &monitor_mapping,
2713                &available_monitors,
2714            ) else {
2715                log::warn!("Could not compute position for window {} in arrangement", i);
2716                continue;
2717            };
2718
2719            let tab_cwds = arrangements::restore::tab_cwds(&arrangement, i);
2720            self.create_window_with_overrides(
2721                event_loop,
2722                (x, y),
2723                (w, h),
2724                &tab_cwds,
2725                window_snapshot.active_tab_index,
2726            );
2727        }
2728
2729        // If no windows were created (e.g., empty arrangement), create one default window
2730        if self.windows.is_empty() {
2731            log::warn!("Arrangement had no restorable windows, creating default window");
2732            self.create_window(event_loop);
2733        }
2734    }
2735
2736    /// Restore an arrangement by name (for auto-restore and keybinding actions)
2737    pub fn restore_arrangement_by_name(
2738        &mut self,
2739        name: &str,
2740        event_loop: &ActiveEventLoop,
2741    ) -> bool {
2742        if let Some(arrangement) = self.arrangement_manager.find_by_name(name) {
2743            let id = arrangement.id;
2744            self.restore_arrangement(id, event_loop);
2745            true
2746        } else {
2747            log::warn!("Arrangement not found by name: {}", name);
2748            false
2749        }
2750    }
2751
2752    /// Delete an arrangement by ID
2753    pub fn delete_arrangement(&mut self, id: ArrangementId) {
2754        if let Some(removed) = self.arrangement_manager.remove(&id) {
2755            log::info!("Deleted arrangement '{}'", removed.name);
2756            if let Err(e) = arrangements::storage::save_arrangements(&self.arrangement_manager) {
2757                log::error!("Failed to save arrangements after delete: {}", e);
2758            }
2759            self.sync_arrangements_to_settings();
2760        }
2761    }
2762
2763    /// Rename an arrangement by ID
2764    pub fn rename_arrangement(&mut self, id: ArrangementId, new_name: String) {
2765        if let Some(arrangement) = self.arrangement_manager.get_mut(&id) {
2766            log::info!(
2767                "Renamed arrangement '{}' -> '{}'",
2768                arrangement.name,
2769                new_name
2770            );
2771            arrangement.name = new_name;
2772            if let Err(e) = arrangements::storage::save_arrangements(&self.arrangement_manager) {
2773                log::error!("Failed to save arrangements after rename: {}", e);
2774            }
2775            self.sync_arrangements_to_settings();
2776        }
2777    }
2778
2779    /// Move an arrangement up in the order
2780    pub fn move_arrangement_up(&mut self, id: ArrangementId) {
2781        self.arrangement_manager.move_up(&id);
2782        if let Err(e) = arrangements::storage::save_arrangements(&self.arrangement_manager) {
2783            log::error!("Failed to save arrangements after reorder: {}", e);
2784        }
2785        self.sync_arrangements_to_settings();
2786    }
2787
2788    /// Move an arrangement down in the order
2789    pub fn move_arrangement_down(&mut self, id: ArrangementId) {
2790        self.arrangement_manager.move_down(&id);
2791        if let Err(e) = arrangements::storage::save_arrangements(&self.arrangement_manager) {
2792            log::error!("Failed to save arrangements after reorder: {}", e);
2793        }
2794        self.sync_arrangements_to_settings();
2795    }
2796
2797    /// Sync arrangement manager data to the settings window (for UI display)
2798    pub fn sync_arrangements_to_settings(&mut self) {
2799        if let Some(sw) = &mut self.settings_window {
2800            sw.settings_ui.arrangement_manager = self.arrangement_manager.clone();
2801        }
2802    }
2803
2804    pub fn send_test_notification(&self) {
2805        log::info!("Sending test notification");
2806
2807        #[cfg(not(target_os = "macos"))]
2808        {
2809            use notify_rust::Notification;
2810            if let Err(e) = Notification::new()
2811                .summary("par-term Test Notification")
2812                .body("If you see this, notifications are working!")
2813                .timeout(notify_rust::Timeout::Milliseconds(5000))
2814                .show()
2815            {
2816                log::warn!("Failed to send test notification: {}", e);
2817            }
2818        }
2819
2820        #[cfg(target_os = "macos")]
2821        {
2822            // macOS notifications via osascript
2823            let script = r#"display notification "If you see this, notifications are working!" with title "par-term Test Notification""#;
2824
2825            if let Err(e) = std::process::Command::new("osascript")
2826                .arg("-e")
2827                .arg(script)
2828                .output()
2829            {
2830                log::warn!("Failed to send macOS test notification: {}", e);
2831            }
2832        }
2833    }
2834}