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