Skip to main content

par_term/pane/
types.rs

1//! Core types for the pane system
2//!
3//! This module defines the fundamental data structures for split panes:
4//! - Binary tree structure for arbitrary nesting
5//! - Per-pane state (terminal, scroll, mouse, etc.)
6//! - Bounds calculation for rendering
7
8use crate::app::bell::BellState;
9use crate::app::mouse::MouseState;
10use crate::app::render_cache::RenderCache;
11use crate::config::Config;
12use crate::scroll_state::ScrollState;
13use crate::session_logger::{SharedSessionLogger, create_shared_logger};
14use crate::tab::build_shell_env;
15use crate::terminal::TerminalManager;
16use std::sync::Arc;
17use tokio::runtime::Runtime;
18use tokio::sync::Mutex;
19use tokio::task::JoinHandle;
20
21// Re-export PaneId from par-term-config for shared access across subcrates
22pub use par_term_config::PaneId;
23
24/// State for shell restart behavior
25#[derive(Debug, Clone)]
26pub enum RestartState {
27    /// Waiting for user to press Enter to restart
28    AwaitingInput,
29    /// Waiting for delay timer before restart
30    AwaitingDelay(std::time::Instant),
31}
32
33/// Direction of a split
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum SplitDirection {
36    /// Panes are stacked vertically (split creates top/bottom panes)
37    Horizontal,
38    /// Panes are side by side (split creates left/right panes)
39    Vertical,
40}
41
42/// Bounds of a pane in pixels
43#[derive(Debug, Clone, Copy, Default)]
44pub struct PaneBounds {
45    /// X position in pixels from left edge of content area
46    pub x: f32,
47    /// Y position in pixels from top of content area (below tab bar)
48    pub y: f32,
49    /// Width in pixels
50    pub width: f32,
51    /// Height in pixels
52    pub height: f32,
53}
54
55impl PaneBounds {
56    /// Create new bounds
57    pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
58        Self {
59            x,
60            y,
61            width,
62            height,
63        }
64    }
65
66    /// Check if a point is inside these bounds
67    pub fn contains(&self, px: f32, py: f32) -> bool {
68        px >= self.x && px < self.x + self.width && py >= self.y && py < self.y + self.height
69    }
70
71    /// Get the center point of the bounds
72    pub fn center(&self) -> (f32, f32) {
73        (self.x + self.width / 2.0, self.y + self.height / 2.0)
74    }
75
76    /// Calculate grid dimensions (cols, rows) given cell dimensions
77    pub fn grid_size(&self, cell_width: f32, cell_height: f32) -> (usize, usize) {
78        let cols = (self.width / cell_width).floor() as usize;
79        let rows = (self.height / cell_height).floor() as usize;
80        (cols.max(1), rows.max(1))
81    }
82}
83
84// Re-export rendering types from par-term-config
85pub use par_term_config::{DividerRect, PaneBackground};
86
87/// A single terminal pane with its own state
88pub struct Pane {
89    /// Unique identifier for this pane
90    pub id: PaneId,
91    /// The terminal session for this pane
92    pub terminal: Arc<Mutex<TerminalManager>>,
93    /// Scroll state for this pane
94    pub scroll_state: ScrollState,
95    /// Mouse state for this pane
96    pub mouse: MouseState,
97    /// Bell state for this pane
98    pub bell: BellState,
99    /// Render cache for this pane
100    pub cache: RenderCache,
101    /// Async task for refresh polling
102    pub refresh_task: Option<JoinHandle<()>>,
103    /// Working directory when pane was created
104    pub working_directory: Option<String>,
105    /// Last time terminal output (activity) was detected
106    pub last_activity_time: std::time::Instant,
107    /// Last terminal update generation seen
108    pub last_seen_generation: u64,
109    /// Last activity time for anti-idle keep-alive
110    pub anti_idle_last_activity: std::time::Instant,
111    /// Last terminal generation recorded for anti-idle tracking
112    pub anti_idle_last_generation: u64,
113    /// Whether silence notification has been sent for current idle period
114    pub silence_notified: bool,
115    /// Whether exit notification has been sent for this pane
116    pub exit_notified: bool,
117    /// Session logger for automatic session recording
118    pub session_logger: SharedSessionLogger,
119    /// Current bounds of this pane (updated on layout calculation)
120    pub bounds: PaneBounds,
121    /// Per-pane background settings (overrides global config if image_path is set)
122    pub background: PaneBackground,
123    /// State for shell restart behavior (None = shell running or closed normally)
124    pub restart_state: Option<RestartState>,
125    /// When true, Drop impl skips cleanup (terminal Arcs are dropped on background threads)
126    pub(crate) shutdown_fast: bool,
127}
128
129impl Pane {
130    /// Create a new pane with a terminal session
131    pub fn new(
132        id: PaneId,
133        config: &Config,
134        _runtime: Arc<Runtime>,
135        working_directory: Option<String>,
136    ) -> anyhow::Result<Self> {
137        // Create terminal with scrollback from config
138        let mut terminal = TerminalManager::new_with_scrollback(
139            config.cols,
140            config.rows,
141            config.scrollback_lines,
142        )?;
143
144        // Set theme from config
145        terminal.set_theme(config.load_theme());
146
147        // Apply clipboard history limits from config
148        terminal.set_max_clipboard_sync_events(config.clipboard_max_sync_events);
149        terminal.set_max_clipboard_event_bytes(config.clipboard_max_event_bytes);
150
151        // Set answerback string for ENQ response (if configured)
152        if !config.answerback_string.is_empty() {
153            terminal.set_answerback_string(Some(config.answerback_string.clone()));
154        }
155
156        // Apply Unicode width configuration
157        let width_config = par_term_emu_core_rust::WidthConfig::new(
158            config.unicode_version,
159            config.ambiguous_width,
160        );
161        terminal.set_width_config(width_config);
162
163        // Apply Unicode normalization form
164        terminal.set_normalization_form(config.normalization_form);
165
166        // Initialize cursor style from config
167        {
168            use crate::config::CursorStyle as ConfigCursorStyle;
169            use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
170            let term_style = if config.cursor_blink {
171                match config.cursor_style {
172                    ConfigCursorStyle::Block => TermCursorStyle::BlinkingBlock,
173                    ConfigCursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
174                    ConfigCursorStyle::Beam => TermCursorStyle::BlinkingBar,
175                }
176            } else {
177                match config.cursor_style {
178                    ConfigCursorStyle::Block => TermCursorStyle::SteadyBlock,
179                    ConfigCursorStyle::Underline => TermCursorStyle::SteadyUnderline,
180                    ConfigCursorStyle::Beam => TermCursorStyle::SteadyBar,
181                }
182            };
183            terminal.set_cursor_style(term_style);
184        }
185
186        // Determine working directory
187        let work_dir = working_directory
188            .as_deref()
189            .or(config.working_directory.as_deref());
190
191        // Determine the shell command to use
192        #[allow(unused_mut)] // mut is needed on Unix for login shell modification
193        let (shell_cmd, mut shell_args) = if let Some(ref custom) = config.custom_shell {
194            (custom.clone(), config.shell_args.clone())
195        } else {
196            #[cfg(target_os = "windows")]
197            {
198                ("powershell.exe".to_string(), None)
199            }
200            #[cfg(not(target_os = "windows"))]
201            {
202                (
203                    std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
204                    None,
205                )
206            }
207        };
208
209        // On Unix-like systems, spawn as login shell if configured
210        #[cfg(not(target_os = "windows"))]
211        if config.login_shell {
212            let args = shell_args.get_or_insert_with(Vec::new);
213            if !args.iter().any(|a| a == "-l" || a == "--login") {
214                args.insert(0, "-l".to_string());
215            }
216        }
217
218        let shell_args_deref = shell_args.as_deref();
219        let shell_env = build_shell_env(config.shell_env.as_ref());
220        terminal.spawn_custom_shell_with_dir(
221            &shell_cmd,
222            shell_args_deref,
223            work_dir,
224            shell_env.as_ref(),
225        )?;
226
227        // Create shared session logger
228        let session_logger = create_shared_logger();
229
230        let terminal = Arc::new(Mutex::new(terminal));
231
232        Ok(Self {
233            id,
234            terminal,
235            scroll_state: ScrollState::new(),
236            mouse: MouseState::new(),
237            bell: BellState::new(),
238            cache: RenderCache::new(),
239            refresh_task: None,
240            working_directory: working_directory.or_else(|| config.working_directory.clone()),
241            last_activity_time: std::time::Instant::now(),
242            last_seen_generation: 0,
243            anti_idle_last_activity: std::time::Instant::now(),
244            anti_idle_last_generation: 0,
245            silence_notified: false,
246            exit_notified: false,
247            session_logger,
248            bounds: PaneBounds::default(),
249            background: PaneBackground::new(),
250            restart_state: None,
251            shutdown_fast: false,
252        })
253    }
254
255    /// Create a new pane for tmux integration (no shell spawned)
256    ///
257    /// This creates a terminal that receives output from tmux control mode
258    /// rather than a local PTY.
259    pub fn new_for_tmux(
260        id: PaneId,
261        config: &Config,
262        _runtime: Arc<Runtime>,
263    ) -> anyhow::Result<Self> {
264        // Create terminal with scrollback from config but don't spawn a shell
265        let mut terminal = TerminalManager::new_with_scrollback(
266            config.cols,
267            config.rows,
268            config.scrollback_lines,
269        )?;
270
271        // Set theme from config
272        terminal.set_theme(config.load_theme());
273
274        // Apply clipboard history limits from config
275        terminal.set_max_clipboard_sync_events(config.clipboard_max_sync_events);
276        terminal.set_max_clipboard_event_bytes(config.clipboard_max_event_bytes);
277
278        // Set answerback string for ENQ response (if configured)
279        if !config.answerback_string.is_empty() {
280            terminal.set_answerback_string(Some(config.answerback_string.clone()));
281        }
282
283        // Apply Unicode width configuration
284        let width_config = par_term_emu_core_rust::WidthConfig::new(
285            config.unicode_version,
286            config.ambiguous_width,
287        );
288        terminal.set_width_config(width_config);
289
290        // Apply Unicode normalization form
291        terminal.set_normalization_form(config.normalization_form);
292
293        // Initialize cursor style from config
294        {
295            use crate::config::CursorStyle as ConfigCursorStyle;
296            use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
297            let term_style = if config.cursor_blink {
298                match config.cursor_style {
299                    ConfigCursorStyle::Block => TermCursorStyle::BlinkingBlock,
300                    ConfigCursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
301                    ConfigCursorStyle::Beam => TermCursorStyle::BlinkingBar,
302                }
303            } else {
304                match config.cursor_style {
305                    ConfigCursorStyle::Block => TermCursorStyle::SteadyBlock,
306                    ConfigCursorStyle::Underline => TermCursorStyle::SteadyUnderline,
307                    ConfigCursorStyle::Beam => TermCursorStyle::SteadyBar,
308                }
309            };
310            terminal.set_cursor_style(term_style);
311        }
312
313        // Don't spawn any shell - tmux provides the output
314        // Create shared session logger
315        let session_logger = create_shared_logger();
316
317        let terminal = Arc::new(Mutex::new(terminal));
318
319        Ok(Self {
320            id,
321            terminal,
322            scroll_state: ScrollState::new(),
323            mouse: MouseState::new(),
324            bell: BellState::new(),
325            cache: RenderCache::new(),
326            refresh_task: None,
327            working_directory: None,
328            last_activity_time: std::time::Instant::now(),
329            last_seen_generation: 0,
330            anti_idle_last_activity: std::time::Instant::now(),
331            anti_idle_last_generation: 0,
332            silence_notified: false,
333            exit_notified: false,
334            session_logger,
335            bounds: PaneBounds::default(),
336            background: PaneBackground::new(),
337            restart_state: None,
338            shutdown_fast: false,
339        })
340    }
341
342    /// Check if the visual bell is currently active
343    pub fn is_bell_active(&self) -> bool {
344        const FLASH_DURATION_MS: u128 = 150;
345        if let Some(flash_start) = self.bell.visual_flash {
346            flash_start.elapsed().as_millis() < FLASH_DURATION_MS
347        } else {
348            false
349        }
350    }
351
352    /// Check if the terminal in this pane is still running
353    pub fn is_running(&self) -> bool {
354        if let Ok(term) = self.terminal.try_lock() {
355            let running = term.is_running();
356            if !running {
357                crate::debug_info!(
358                    "PANE_EXIT",
359                    "Pane {} terminal detected as NOT running (shell exited)",
360                    self.id
361                );
362            }
363            running
364        } else {
365            true // Assume running if locked
366        }
367    }
368
369    /// Get the current working directory of this pane's shell
370    pub fn get_cwd(&self) -> Option<String> {
371        if let Ok(term) = self.terminal.try_lock() {
372            term.shell_integration_cwd()
373        } else {
374            self.working_directory.clone()
375        }
376    }
377
378    /// Set per-pane background settings (overrides global config)
379    pub fn set_background(&mut self, background: PaneBackground) {
380        self.background = background;
381    }
382
383    /// Get per-pane background settings
384    pub fn background(&self) -> &PaneBackground {
385        &self.background
386    }
387
388    /// Set a per-pane background image (overrides global config)
389    pub fn set_background_image(&mut self, path: Option<String>) {
390        self.background.image_path = path;
391    }
392
393    /// Get the per-pane background image path (if set)
394    pub fn get_background_image(&self) -> Option<&str> {
395        self.background.image_path.as_deref()
396    }
397
398    /// Respawn the shell in this pane
399    ///
400    /// This resets the terminal state and spawns a new shell process.
401    /// Used when shell_exit_action is one of the restart variants.
402    pub fn respawn_shell(&mut self, config: &Config) -> anyhow::Result<()> {
403        // Clear restart state
404        self.restart_state = None;
405        self.exit_notified = false;
406
407        // Determine the shell command to use
408        #[allow(unused_mut)]
409        let (shell_cmd, mut shell_args) = if let Some(ref custom) = config.custom_shell {
410            (custom.clone(), config.shell_args.clone())
411        } else {
412            #[cfg(target_os = "windows")]
413            {
414                ("powershell.exe".to_string(), None)
415            }
416            #[cfg(not(target_os = "windows"))]
417            {
418                (
419                    std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
420                    None,
421                )
422            }
423        };
424
425        // On Unix-like systems, spawn as login shell if configured
426        #[cfg(not(target_os = "windows"))]
427        if config.login_shell {
428            let args = shell_args.get_or_insert_with(Vec::new);
429            if !args.iter().any(|a| a == "-l" || a == "--login") {
430                args.insert(0, "-l".to_string());
431            }
432        }
433
434        // Determine working directory (use current CWD if available, else config)
435        let work_dir = self
436            .get_cwd()
437            .or_else(|| self.working_directory.clone())
438            .or_else(|| config.working_directory.clone());
439
440        let shell_args_deref = shell_args.as_deref();
441        let shell_env = build_shell_env(config.shell_env.as_ref());
442
443        // Respawn the shell
444        if let Ok(mut term) = self.terminal.try_lock() {
445            // Clear the screen before respawning (using VT escape sequence)
446            // This clears screen and moves cursor to home position
447            term.process_data(b"\x1b[2J\x1b[H");
448
449            // Spawn new shell
450            term.spawn_custom_shell_with_dir(
451                &shell_cmd,
452                shell_args_deref,
453                work_dir.as_deref(),
454                shell_env.as_ref(),
455            )?;
456
457            log::info!("Respawned shell in pane {}", self.id);
458        }
459
460        Ok(())
461    }
462
463    /// Write a restart prompt message to the terminal
464    pub fn write_restart_prompt(&self) {
465        if let Ok(term) = self.terminal.try_lock() {
466            // Write the prompt message directly to terminal display
467            let message = "\r\n[Process exited. Press Enter to restart...]\r\n";
468            term.process_data(message.as_bytes());
469        }
470    }
471
472    /// Get the title for this pane (from OSC or CWD)
473    pub fn get_title(&self) -> String {
474        if let Ok(term) = self.terminal.try_lock() {
475            let osc_title = term.get_title();
476            if !osc_title.is_empty() {
477                return osc_title;
478            }
479            if let Some(cwd) = term.shell_integration_cwd() {
480                // Abbreviate home directory to ~
481                let abbreviated = if let Some(home) = dirs::home_dir() {
482                    cwd.replace(&home.to_string_lossy().to_string(), "~")
483                } else {
484                    cwd
485                };
486                // Use just the last component for brevity
487                if let Some(last) = abbreviated.rsplit('/').next()
488                    && !last.is_empty()
489                {
490                    return last.to_string();
491                }
492                return abbreviated;
493            }
494        }
495        format!("Pane {}", self.id)
496    }
497
498    /// Start the refresh polling task for this pane
499    pub fn start_refresh_task(
500        &mut self,
501        runtime: Arc<Runtime>,
502        window: Arc<winit::window::Window>,
503        max_fps: u32,
504    ) {
505        let terminal_clone = Arc::clone(&self.terminal);
506        let refresh_interval_ms = 1000 / max_fps.max(1);
507
508        let handle = runtime.spawn(async move {
509            let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(
510                refresh_interval_ms as u64,
511            ));
512            let mut last_gen = 0;
513
514            loop {
515                interval.tick().await;
516
517                let should_redraw = if let Ok(term) = terminal_clone.try_lock() {
518                    let current_gen = term.update_generation();
519                    if current_gen > last_gen {
520                        last_gen = current_gen;
521                        true
522                    } else {
523                        term.has_updates()
524                    }
525                } else {
526                    false
527                };
528
529                if should_redraw {
530                    window.request_redraw();
531                }
532            }
533        });
534
535        self.refresh_task = Some(handle);
536    }
537
538    /// Stop the refresh polling task
539    pub fn stop_refresh_task(&mut self) {
540        if let Some(handle) = self.refresh_task.take() {
541            handle.abort();
542        }
543    }
544
545    /// Resize the terminal to match the pane bounds
546    pub fn resize_terminal(&self, cols: usize, rows: usize) {
547        if let Ok(mut term) = self.terminal.try_lock()
548            && term.dimensions() != (cols, rows)
549        {
550            let _ = term.resize(cols, rows);
551        }
552    }
553}
554
555impl Drop for Pane {
556    fn drop(&mut self) {
557        if self.shutdown_fast {
558            log::info!(
559                "Fast-dropping pane {} (cleanup handled externally)",
560                self.id
561            );
562            return;
563        }
564
565        log::info!("Dropping pane {}", self.id);
566
567        // Stop session logging first
568        if let Some(ref mut logger) = *self.session_logger.lock() {
569            match logger.stop() {
570                Ok(path) => {
571                    log::info!("Session log saved to: {:?}", path);
572                }
573                Err(e) => {
574                    log::warn!("Failed to stop session logging: {}", e);
575                }
576            }
577        }
578
579        self.stop_refresh_task();
580
581        // Give the task time to abort
582        std::thread::sleep(std::time::Duration::from_millis(50));
583
584        // Kill the terminal
585        if let Ok(mut term) = self.terminal.try_lock()
586            && term.is_running()
587        {
588            log::info!("Killing terminal for pane {}", self.id);
589            let _ = term.kill();
590        }
591    }
592}
593
594/// Tree node for pane layout
595///
596/// The pane tree is a binary tree where:
597/// - Leaf nodes contain actual terminal panes
598/// - Split nodes contain two children with a split direction and ratio
599pub enum PaneNode {
600    /// A leaf node containing a terminal pane
601    Leaf(Box<Pane>),
602    /// A split containing two child nodes
603    Split {
604        /// Direction of the split
605        direction: SplitDirection,
606        /// Split ratio (0.0 to 1.0) - position of divider
607        /// For horizontal: ratio is height of first child / total height
608        /// For vertical: ratio is width of first child / total width
609        ratio: f32,
610        /// First child (top for horizontal, left for vertical)
611        first: Box<PaneNode>,
612        /// Second child (bottom for horizontal, right for vertical)
613        second: Box<PaneNode>,
614    },
615}
616
617impl PaneNode {
618    /// Create a new leaf node with a pane
619    pub fn leaf(pane: Pane) -> Self {
620        PaneNode::Leaf(Box::new(pane))
621    }
622
623    /// Create a new split node
624    pub fn split(direction: SplitDirection, ratio: f32, first: PaneNode, second: PaneNode) -> Self {
625        PaneNode::Split {
626            direction,
627            ratio: ratio.clamp(0.1, 0.9), // Enforce minimum pane size
628            first: Box::new(first),
629            second: Box::new(second),
630        }
631    }
632
633    /// Check if this is a leaf node
634    pub fn is_leaf(&self) -> bool {
635        matches!(self, PaneNode::Leaf(_))
636    }
637
638    /// Get the pane if this is a leaf node
639    pub fn as_pane(&self) -> Option<&Pane> {
640        match self {
641            PaneNode::Leaf(pane) => Some(pane),
642            PaneNode::Split { .. } => None,
643        }
644    }
645
646    /// Get mutable pane if this is a leaf node
647    pub fn as_pane_mut(&mut self) -> Option<&mut Pane> {
648        match self {
649            PaneNode::Leaf(pane) => Some(pane),
650            PaneNode::Split { .. } => None,
651        }
652    }
653
654    /// Find a pane by ID (recursive)
655    pub fn find_pane(&self, id: PaneId) -> Option<&Pane> {
656        match self {
657            PaneNode::Leaf(pane) => {
658                if pane.id == id {
659                    Some(pane)
660                } else {
661                    None
662                }
663            }
664            PaneNode::Split { first, second, .. } => {
665                first.find_pane(id).or_else(|| second.find_pane(id))
666            }
667        }
668    }
669
670    /// Find a mutable pane by ID (recursive)
671    pub fn find_pane_mut(&mut self, id: PaneId) -> Option<&mut Pane> {
672        match self {
673            PaneNode::Leaf(pane) => {
674                if pane.id == id {
675                    Some(pane)
676                } else {
677                    None
678                }
679            }
680            PaneNode::Split { first, second, .. } => first
681                .find_pane_mut(id)
682                .or_else(move || second.find_pane_mut(id)),
683        }
684    }
685
686    /// Find the pane at a given pixel position
687    pub fn find_pane_at(&self, x: f32, y: f32) -> Option<&Pane> {
688        match self {
689            PaneNode::Leaf(pane) => {
690                if pane.bounds.contains(x, y) {
691                    Some(pane)
692                } else {
693                    None
694                }
695            }
696            PaneNode::Split { first, second, .. } => first
697                .find_pane_at(x, y)
698                .or_else(|| second.find_pane_at(x, y)),
699        }
700    }
701
702    /// Get all pane IDs in this subtree
703    pub fn all_pane_ids(&self) -> Vec<PaneId> {
704        match self {
705            PaneNode::Leaf(pane) => vec![pane.id],
706            PaneNode::Split { first, second, .. } => {
707                let mut ids = first.all_pane_ids();
708                ids.extend(second.all_pane_ids());
709                ids
710            }
711        }
712    }
713
714    /// Get all panes in this subtree
715    pub fn all_panes(&self) -> Vec<&Pane> {
716        match self {
717            PaneNode::Leaf(pane) => vec![pane],
718            PaneNode::Split { first, second, .. } => {
719                let mut panes = first.all_panes();
720                panes.extend(second.all_panes());
721                panes
722            }
723        }
724    }
725
726    /// Get all mutable panes in this subtree
727    pub fn all_panes_mut(&mut self) -> Vec<&mut Pane> {
728        match self {
729            PaneNode::Leaf(pane) => vec![pane],
730            PaneNode::Split { first, second, .. } => {
731                let mut panes = first.all_panes_mut();
732                panes.extend(second.all_panes_mut());
733                panes
734            }
735        }
736    }
737
738    /// Count total number of panes
739    pub fn pane_count(&self) -> usize {
740        match self {
741            PaneNode::Leaf(_) => 1,
742            PaneNode::Split { first, second, .. } => first.pane_count() + second.pane_count(),
743        }
744    }
745
746    /// Calculate bounds for all panes given the total available area
747    ///
748    /// This recursively distributes space according to split ratios
749    /// and updates each pane's bounds field.
750    pub fn calculate_bounds(&mut self, bounds: PaneBounds, divider_width: f32) {
751        match self {
752            PaneNode::Leaf(pane) => {
753                pane.bounds = bounds;
754            }
755            PaneNode::Split {
756                direction,
757                ratio,
758                first,
759                second,
760            } => {
761                let (first_bounds, second_bounds) = match direction {
762                    SplitDirection::Horizontal => {
763                        // Split vertically (panes stacked top/bottom)
764                        let first_height = (bounds.height - divider_width) * *ratio;
765                        let second_height = bounds.height - first_height - divider_width;
766                        (
767                            PaneBounds::new(bounds.x, bounds.y, bounds.width, first_height),
768                            PaneBounds::new(
769                                bounds.x,
770                                bounds.y + first_height + divider_width,
771                                bounds.width,
772                                second_height,
773                            ),
774                        )
775                    }
776                    SplitDirection::Vertical => {
777                        // Split horizontally (panes side by side)
778                        let first_width = (bounds.width - divider_width) * *ratio;
779                        let second_width = bounds.width - first_width - divider_width;
780                        (
781                            PaneBounds::new(bounds.x, bounds.y, first_width, bounds.height),
782                            PaneBounds::new(
783                                bounds.x + first_width + divider_width,
784                                bounds.y,
785                                second_width,
786                                bounds.height,
787                            ),
788                        )
789                    }
790                };
791
792                first.calculate_bounds(first_bounds, divider_width);
793                second.calculate_bounds(second_bounds, divider_width);
794            }
795        }
796    }
797
798    /// Find the closest pane in a given direction from the focused pane
799    ///
800    /// Returns the pane ID of the closest pane in the specified direction,
801    /// or None if there is no pane in that direction.
802    pub fn find_pane_in_direction(
803        &self,
804        from_id: PaneId,
805        direction: NavigationDirection,
806    ) -> Option<PaneId> {
807        // Get the bounds of the source pane
808        let from_pane = self.find_pane(from_id)?;
809        let from_center = from_pane.bounds.center();
810
811        // Get all other panes
812        let all_panes = self.all_panes();
813
814        // Filter panes that are in the correct direction and find the closest
815        let mut best: Option<(PaneId, f32)> = None;
816
817        for pane in all_panes {
818            if pane.id == from_id {
819                continue;
820            }
821
822            let pane_center = pane.bounds.center();
823            let is_in_direction = match direction {
824                NavigationDirection::Left => pane_center.0 < from_center.0,
825                NavigationDirection::Right => pane_center.0 > from_center.0,
826                NavigationDirection::Up => pane_center.1 < from_center.1,
827                NavigationDirection::Down => pane_center.1 > from_center.1,
828            };
829
830            if is_in_direction {
831                // Calculate distance (Manhattan distance works well for grid-like layouts)
832                let dx = (pane_center.0 - from_center.0).abs();
833                let dy = (pane_center.1 - from_center.1).abs();
834
835                // Weight the primary direction more heavily
836                let distance = match direction {
837                    NavigationDirection::Left | NavigationDirection::Right => dx + dy * 2.0,
838                    NavigationDirection::Up | NavigationDirection::Down => dy + dx * 2.0,
839                };
840
841                if best.is_none() || distance < best.unwrap().1 {
842                    best = Some((pane.id, distance));
843                }
844            }
845        }
846
847        best.map(|(id, _)| id)
848    }
849
850    /// Collect all divider rectangles in the pane tree
851    ///
852    /// Returns a list of DividerRect structures that can be used for:
853    /// - Rendering divider lines between panes
854    /// - Hit testing for mouse drag resize
855    pub fn collect_dividers(&self, bounds: PaneBounds, divider_width: f32) -> Vec<DividerRect> {
856        let mut dividers = Vec::new();
857        self.collect_dividers_recursive(bounds, divider_width, &mut dividers);
858        dividers
859    }
860
861    /// Recursive helper for collecting dividers
862    fn collect_dividers_recursive(
863        &self,
864        bounds: PaneBounds,
865        divider_width: f32,
866        dividers: &mut Vec<DividerRect>,
867    ) {
868        match self {
869            PaneNode::Leaf(_) => {
870                // Leaf nodes have no dividers
871            }
872            PaneNode::Split {
873                direction,
874                ratio,
875                first,
876                second,
877            } => {
878                // Calculate divider position and child bounds
879                let (first_bounds, divider, second_bounds) = match direction {
880                    SplitDirection::Horizontal => {
881                        // Horizontal split: panes stacked top/bottom, divider is horizontal line
882                        let first_height = (bounds.height - divider_width) * *ratio;
883                        let second_height = bounds.height - first_height - divider_width;
884                        (
885                            PaneBounds::new(bounds.x, bounds.y, bounds.width, first_height),
886                            DividerRect::new(
887                                bounds.x,
888                                bounds.y + first_height,
889                                bounds.width,
890                                divider_width,
891                                true, // is_horizontal
892                            ),
893                            PaneBounds::new(
894                                bounds.x,
895                                bounds.y + first_height + divider_width,
896                                bounds.width,
897                                second_height,
898                            ),
899                        )
900                    }
901                    SplitDirection::Vertical => {
902                        // Vertical split: panes side by side, divider is vertical line
903                        let first_width = (bounds.width - divider_width) * *ratio;
904                        let second_width = bounds.width - first_width - divider_width;
905                        (
906                            PaneBounds::new(bounds.x, bounds.y, first_width, bounds.height),
907                            DividerRect::new(
908                                bounds.x + first_width,
909                                bounds.y,
910                                divider_width,
911                                bounds.height,
912                                false, // is_horizontal (it's vertical)
913                            ),
914                            PaneBounds::new(
915                                bounds.x + first_width + divider_width,
916                                bounds.y,
917                                second_width,
918                                bounds.height,
919                            ),
920                        )
921                    }
922                };
923
924                // Add this divider
925                dividers.push(divider);
926
927                // Recurse into children
928                first.collect_dividers_recursive(first_bounds, divider_width, dividers);
929                second.collect_dividers_recursive(second_bounds, divider_width, dividers);
930            }
931        }
932    }
933}
934
935/// Direction for pane navigation
936#[derive(Debug, Clone, Copy, PartialEq, Eq)]
937pub enum NavigationDirection {
938    Left,
939    Right,
940    Up,
941    Down,
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    #[test]
949    fn test_pane_bounds_contains() {
950        let bounds = PaneBounds::new(10.0, 20.0, 100.0, 50.0);
951
952        // Inside
953        assert!(bounds.contains(50.0, 40.0));
954        assert!(bounds.contains(10.0, 20.0)); // Top-left corner
955
956        // Outside
957        assert!(!bounds.contains(5.0, 40.0)); // Left of bounds
958        assert!(!bounds.contains(150.0, 40.0)); // Right of bounds
959        assert!(!bounds.contains(50.0, 10.0)); // Above bounds
960        assert!(!bounds.contains(50.0, 80.0)); // Below bounds
961    }
962
963    #[test]
964    fn test_pane_bounds_grid_size() {
965        let bounds = PaneBounds::new(0.0, 0.0, 800.0, 600.0);
966        let (cols, rows) = bounds.grid_size(10.0, 20.0);
967        assert_eq!(cols, 80);
968        assert_eq!(rows, 30);
969    }
970
971    #[test]
972    fn test_split_direction_clone() {
973        let dir = SplitDirection::Horizontal;
974        let cloned = dir;
975        assert_eq!(dir, cloned);
976    }
977}