Skip to main content

par_term/pane/
manager.rs

1//! Pane manager for coordinating pane operations within a tab
2//!
3//! The PaneManager owns the pane tree and provides operations for:
4//! - Splitting panes horizontally and vertically
5//! - Closing panes
6//! - Navigating between panes
7//! - Resizing panes
8
9use super::types::{
10    DividerRect, NavigationDirection, Pane, PaneBounds, PaneId, PaneNode, SplitDirection,
11};
12use crate::config::{Config, PaneBackgroundConfig};
13use crate::session::SessionPaneNode;
14use crate::tmux::{LayoutNode, TmuxLayout, TmuxPaneId};
15use anyhow::Result;
16use std::collections::HashMap;
17use std::sync::Arc;
18use tokio::runtime::Runtime;
19
20/// Manages the pane tree within a single tab
21pub struct PaneManager {
22    /// Root of the pane tree (None if no panes yet)
23    root: Option<PaneNode>,
24    /// ID of the currently focused pane
25    focused_pane_id: Option<PaneId>,
26    /// Counter for generating unique pane IDs
27    next_pane_id: PaneId,
28    /// Width of dividers between panes in pixels
29    divider_width: f32,
30    /// Width of the hit area for divider drag detection
31    divider_hit_width: f32,
32    /// Current total bounds available for panes
33    total_bounds: PaneBounds,
34}
35
36impl PaneManager {
37    /// Create a new empty pane manager
38    pub fn new() -> Self {
39        Self {
40            root: None,
41            focused_pane_id: None,
42            next_pane_id: 1,
43            divider_width: 1.0,     // Default 1 pixel divider
44            divider_hit_width: 8.0, // Default 8 pixel hit area
45            total_bounds: PaneBounds::default(),
46        }
47    }
48
49    /// Create a pane manager with an initial pane
50    pub fn with_initial_pane(
51        config: &Config,
52        runtime: Arc<Runtime>,
53        working_directory: Option<String>,
54    ) -> Result<Self> {
55        let mut manager = Self::new();
56        manager.divider_width = config.pane_divider_width.unwrap_or(1.0);
57        manager.divider_hit_width = config.pane_divider_hit_width;
58        manager.create_initial_pane(config, runtime, working_directory)?;
59        Ok(manager)
60    }
61
62    /// Create the initial pane (when tab is first created)
63    ///
64    /// If bounds have been set on the PaneManager via `set_bounds()`, the pane
65    /// will be created with dimensions calculated from those bounds. Otherwise,
66    /// the default config dimensions are used.
67    pub fn create_initial_pane(
68        &mut self,
69        config: &Config,
70        runtime: Arc<Runtime>,
71        working_directory: Option<String>,
72    ) -> Result<PaneId> {
73        self.create_initial_pane_internal(None, config, runtime, working_directory)
74    }
75
76    /// Create the initial pane sized for an upcoming split
77    ///
78    /// This calculates dimensions based on what the pane size will be AFTER
79    /// the split, preventing the shell from seeing a resize.
80    pub fn create_initial_pane_for_split(
81        &mut self,
82        direction: SplitDirection,
83        config: &Config,
84        runtime: Arc<Runtime>,
85        working_directory: Option<String>,
86    ) -> Result<PaneId> {
87        self.create_initial_pane_internal(Some(direction), config, runtime, working_directory)
88    }
89
90    /// Internal method to create initial pane with optional split direction
91    fn create_initial_pane_internal(
92        &mut self,
93        split_direction: Option<SplitDirection>,
94        config: &Config,
95        runtime: Arc<Runtime>,
96        working_directory: Option<String>,
97    ) -> Result<PaneId> {
98        let id = self.next_pane_id;
99        self.next_pane_id += 1;
100
101        // Calculate dimensions from bounds if available
102        let pane_config = if self.total_bounds.width > 0.0 && self.total_bounds.height > 0.0 {
103            // Approximate cell dimensions from font size
104            let cell_width = config.font_size * 0.6; // Approximate monospace char width
105            let cell_height = config.font_size * 1.2; // Approximate line height
106
107            // Calculate bounds accounting for upcoming split
108            let effective_bounds = match split_direction {
109                Some(SplitDirection::Vertical) => {
110                    // After vertical split, this pane will have half the width
111                    PaneBounds::new(
112                        self.total_bounds.x,
113                        self.total_bounds.y,
114                        (self.total_bounds.width - self.divider_width) / 2.0,
115                        self.total_bounds.height,
116                    )
117                }
118                Some(SplitDirection::Horizontal) => {
119                    // After horizontal split, this pane will have half the height
120                    PaneBounds::new(
121                        self.total_bounds.x,
122                        self.total_bounds.y,
123                        self.total_bounds.width,
124                        (self.total_bounds.height - self.divider_width) / 2.0,
125                    )
126                }
127                None => self.total_bounds,
128            };
129
130            let (cols, rows) = effective_bounds.grid_size(cell_width, cell_height);
131
132            let mut cfg = config.clone();
133            cfg.cols = cols.max(10);
134            cfg.rows = rows.max(5);
135            log::info!(
136                "Initial pane {} using bounds-based dimensions: {}x{} (split={:?})",
137                id,
138                cfg.cols,
139                cfg.rows,
140                split_direction
141            );
142            cfg
143        } else {
144            log::info!(
145                "Initial pane {} using config dimensions: {}x{}",
146                id,
147                config.cols,
148                config.rows
149            );
150            config.clone()
151        };
152
153        let mut pane = Pane::new(id, &pane_config, runtime, working_directory)?;
154
155        // Apply per-pane background from config if available (index 0 for initial pane)
156        if let Some((image_path, mode, opacity, darken)) = config.get_pane_background(0) {
157            pane.set_background(crate::pane::PaneBackground {
158                image_path: Some(image_path),
159                mode,
160                opacity,
161                darken,
162            });
163        }
164
165        self.root = Some(PaneNode::leaf(pane));
166        self.focused_pane_id = Some(id);
167
168        Ok(id)
169    }
170
171    /// Split the focused pane in the given direction
172    ///
173    /// Returns the ID of the new pane, or None if no pane is focused
174    pub fn split(
175        &mut self,
176        direction: SplitDirection,
177        config: &Config,
178        runtime: Arc<Runtime>,
179    ) -> Result<Option<PaneId>> {
180        let focused_id = match self.focused_pane_id {
181            Some(id) => id,
182            None => return Ok(None),
183        };
184
185        // Get the working directory and bounds from the focused pane
186        let (working_dir, focused_bounds) = if let Some(pane) = self.focused_pane() {
187            (pane.get_cwd(), pane.bounds)
188        } else {
189            (None, self.total_bounds)
190        };
191
192        // Calculate approximate dimensions for the new pane (half of focused pane)
193        let (new_cols, new_rows) = match direction {
194            SplitDirection::Vertical => {
195                // New pane gets half the width
196                let half_width = (focused_bounds.width - self.divider_width) / 2.0;
197                let cols = (half_width / config.font_size * 1.8).floor() as usize; // Approximate
198                (cols.max(10), config.rows)
199            }
200            SplitDirection::Horizontal => {
201                // New pane gets half the height
202                let half_height = (focused_bounds.height - self.divider_width) / 2.0;
203                let rows = (half_height / (config.font_size * 1.2)).floor() as usize; // Approximate
204                (config.cols, rows.max(5))
205            }
206        };
207
208        // Create a modified config with the approximate dimensions
209        let mut pane_config = config.clone();
210        pane_config.cols = new_cols;
211        pane_config.rows = new_rows;
212
213        // Create the new pane with approximate dimensions
214        let new_id = self.next_pane_id;
215        self.next_pane_id += 1;
216
217        let mut new_pane = Pane::new(new_id, &pane_config, runtime, working_dir)?;
218
219        // Apply per-pane background from config if available
220        // The new pane will be at the end of the pane list, so its index is the current count
221        let new_pane_index = self.pane_count(); // current count = index of new pane after insertion
222        if let Some((image_path, mode, opacity, darken)) =
223            config.get_pane_background(new_pane_index)
224        {
225            new_pane.set_background(crate::pane::PaneBackground {
226                image_path: Some(image_path),
227                mode,
228                opacity,
229                darken,
230            });
231        }
232
233        // Find and split the focused pane
234        if let Some(root) = self.root.take() {
235            let (new_root, _) = Self::split_node(root, focused_id, direction, Some(new_pane));
236            self.root = Some(new_root);
237        }
238
239        // Recalculate bounds
240        self.recalculate_bounds();
241
242        // Focus the new pane
243        self.focused_pane_id = Some(new_id);
244
245        crate::debug_info!(
246            "PANE_SPLIT",
247            "Split pane {} {:?}, created new pane {}. First(left/top)={} Second(right/bottom)={} (focused)",
248            focused_id,
249            direction,
250            new_id,
251            focused_id,
252            new_id
253        );
254
255        Ok(Some(new_id))
256    }
257
258    /// Split a node, finding the target pane and replacing it with a split
259    ///
260    /// Returns (new_node, remaining_pane) where remaining_pane is Some if
261    /// the target was not found in this subtree.
262    fn split_node(
263        node: PaneNode,
264        target_id: PaneId,
265        direction: SplitDirection,
266        new_pane: Option<Pane>,
267    ) -> (PaneNode, Option<Pane>) {
268        match node {
269            PaneNode::Leaf(pane) => {
270                if pane.id == target_id {
271                    if let Some(new) = new_pane {
272                        // This is the pane to split - create a new split node
273                        (
274                            PaneNode::split(
275                                direction,
276                                0.5, // 50/50 split
277                                PaneNode::leaf(*pane),
278                                PaneNode::leaf(new),
279                            ),
280                            None,
281                        )
282                    } else {
283                        // No pane to insert (shouldn't happen)
284                        (PaneNode::Leaf(pane), None)
285                    }
286                } else {
287                    // Not the target, keep as-is and pass the new_pane through
288                    (PaneNode::Leaf(pane), new_pane)
289                }
290            }
291            PaneNode::Split {
292                direction: split_dir,
293                ratio,
294                first,
295                second,
296            } => {
297                // Try to insert in first child
298                let (new_first, remaining) =
299                    Self::split_node(*first, target_id, direction, new_pane);
300
301                if remaining.is_none() {
302                    // Target was found in first child
303                    (
304                        PaneNode::Split {
305                            direction: split_dir,
306                            ratio,
307                            first: Box::new(new_first),
308                            second,
309                        },
310                        None,
311                    )
312                } else {
313                    // Target not in first, try second
314                    let (new_second, remaining) =
315                        Self::split_node(*second, target_id, direction, remaining);
316                    (
317                        PaneNode::Split {
318                            direction: split_dir,
319                            ratio,
320                            first: Box::new(new_first),
321                            second: Box::new(new_second),
322                        },
323                        remaining,
324                    )
325                }
326            }
327        }
328    }
329
330    /// Close a pane by ID
331    ///
332    /// Returns true if this was the last pane (tab should close)
333    pub fn close_pane(&mut self, id: PaneId) -> bool {
334        crate::debug_info!("PANE_CLOSE", "close_pane called for pane {}", id);
335
336        if let Some(root) = self.root.take() {
337            match Self::remove_pane(root, id) {
338                RemoveResult::Removed(new_root) => {
339                    self.root = new_root;
340
341                    // If we closed the focused pane, focus another
342                    if self.focused_pane_id == Some(id) {
343                        let new_focus = self
344                            .root
345                            .as_ref()
346                            .and_then(|r| r.all_pane_ids().first().copied());
347                        crate::debug_info!(
348                            "PANE_CLOSE",
349                            "Closed focused pane {}, new focus: {:?}",
350                            id,
351                            new_focus
352                        );
353                        self.focused_pane_id = new_focus;
354                    }
355
356                    // Recalculate bounds
357                    self.recalculate_bounds();
358
359                    // Log remaining panes after closure
360                    if let Some(ref root) = self.root {
361                        for pane_id in root.all_pane_ids() {
362                            if let Some(pane) = self.get_pane(pane_id) {
363                                crate::debug_info!(
364                                    "PANE_CLOSE",
365                                    "Remaining pane {} bounds=({:.0},{:.0} {:.0}x{:.0})",
366                                    pane.id,
367                                    pane.bounds.x,
368                                    pane.bounds.y,
369                                    pane.bounds.width,
370                                    pane.bounds.height
371                                );
372                            }
373                        }
374                    }
375
376                    crate::debug_info!("PANE_CLOSE", "Successfully closed pane {}", id);
377                }
378                RemoveResult::NotFound(root) => {
379                    crate::debug_info!("PANE_CLOSE", "Pane {} not found in tree", id);
380                    self.root = Some(root);
381                }
382            }
383        }
384
385        self.root.is_none()
386    }
387
388    /// Remove a pane from the tree, returning the new tree structure
389    fn remove_pane(node: PaneNode, target_id: PaneId) -> RemoveResult {
390        match node {
391            PaneNode::Leaf(pane) => {
392                if pane.id == target_id {
393                    // This pane should be removed
394                    RemoveResult::Removed(None)
395                } else {
396                    RemoveResult::NotFound(PaneNode::Leaf(pane))
397                }
398            }
399            PaneNode::Split {
400                direction,
401                ratio,
402                first,
403                second,
404            } => {
405                // Try to remove from first child
406                match Self::remove_pane(*first, target_id) {
407                    RemoveResult::Removed(None) => {
408                        // First child was the target and is now gone
409                        // Replace this split with the second child
410                        RemoveResult::Removed(Some(*second))
411                    }
412                    RemoveResult::Removed(Some(new_first)) => {
413                        // First child was modified
414                        RemoveResult::Removed(Some(PaneNode::Split {
415                            direction,
416                            ratio,
417                            first: Box::new(new_first),
418                            second,
419                        }))
420                    }
421                    RemoveResult::NotFound(first_node) => {
422                        // Target not in first child, try second
423                        match Self::remove_pane(*second, target_id) {
424                            RemoveResult::Removed(None) => {
425                                // Second child was the target and is now gone
426                                // Replace this split with the first child
427                                RemoveResult::Removed(Some(first_node))
428                            }
429                            RemoveResult::Removed(Some(new_second)) => {
430                                // Second child was modified
431                                RemoveResult::Removed(Some(PaneNode::Split {
432                                    direction,
433                                    ratio,
434                                    first: Box::new(first_node),
435                                    second: Box::new(new_second),
436                                }))
437                            }
438                            RemoveResult::NotFound(second_node) => {
439                                // Target not found in either child
440                                RemoveResult::NotFound(PaneNode::Split {
441                                    direction,
442                                    ratio,
443                                    first: Box::new(first_node),
444                                    second: Box::new(second_node),
445                                })
446                            }
447                        }
448                    }
449                }
450            }
451        }
452    }
453
454    /// Navigate to a pane in the given direction
455    pub fn navigate(&mut self, direction: NavigationDirection) {
456        if let Some(focused_id) = self.focused_pane_id
457            && let Some(ref root) = self.root
458            && let Some(new_id) = root.find_pane_in_direction(focused_id, direction)
459        {
460            self.focused_pane_id = Some(new_id);
461            log::debug!(
462                "Navigated {:?} from pane {} to pane {}",
463                direction,
464                focused_id,
465                new_id
466            );
467        }
468    }
469
470    /// Focus a specific pane by ID
471    pub fn focus_pane(&mut self, id: PaneId) {
472        if self
473            .root
474            .as_ref()
475            .is_some_and(|r| r.find_pane(id).is_some())
476        {
477            self.focused_pane_id = Some(id);
478        }
479    }
480
481    /// Focus the pane at a given pixel position
482    pub fn focus_pane_at(&mut self, x: f32, y: f32) -> Option<PaneId> {
483        if let Some(ref root) = self.root
484            && let Some(pane) = root.find_pane_at(x, y)
485        {
486            let id = pane.id;
487            self.focused_pane_id = Some(id);
488            return Some(id);
489        }
490        None
491    }
492
493    /// Get the currently focused pane
494    pub fn focused_pane(&self) -> Option<&Pane> {
495        self.focused_pane_id
496            .and_then(|id| self.root.as_ref()?.find_pane(id))
497    }
498
499    /// Get the currently focused pane mutably
500    pub fn focused_pane_mut(&mut self) -> Option<&mut Pane> {
501        let id = self.focused_pane_id?;
502        self.root.as_mut()?.find_pane_mut(id)
503    }
504
505    /// Get the focused pane ID
506    pub fn focused_pane_id(&self) -> Option<PaneId> {
507        self.focused_pane_id
508    }
509
510    /// Get the next pane ID that will be assigned
511    pub fn next_pane_id(&self) -> PaneId {
512        self.next_pane_id
513    }
514
515    /// Add a pane for tmux integration (doesn't create split, just adds to flat structure)
516    ///
517    /// This is used when tmux splits a pane - we need to add a new native pane
518    /// without restructuring our tree (tmux layout update will handle that).
519    pub fn add_pane_for_tmux(&mut self, pane: Pane) {
520        let pane_id = pane.id;
521
522        // Update next_pane_id if needed
523        if pane_id >= self.next_pane_id {
524            self.next_pane_id = pane_id + 1;
525        }
526
527        // If no root, this becomes the root
528        if self.root.is_none() {
529            self.root = Some(PaneNode::leaf(pane));
530            self.focused_pane_id = Some(pane_id);
531            return;
532        }
533
534        // Otherwise, we need to add it to the tree structure
535        // For now, we'll create a simple vertical split with the new pane
536        // The actual layout will be corrected by update_layout_from_tmux
537        if let Some(existing_root) = self.root.take() {
538            self.root = Some(PaneNode::Split {
539                direction: SplitDirection::Vertical,
540                ratio: 0.5,
541                first: Box::new(existing_root),
542                second: Box::new(PaneNode::leaf(pane)),
543            });
544        }
545
546        // Focus the new pane
547        self.focused_pane_id = Some(pane_id);
548    }
549
550    /// Get a pane by ID
551    pub fn get_pane(&self, id: PaneId) -> Option<&Pane> {
552        self.root.as_ref()?.find_pane(id)
553    }
554
555    /// Get a mutable pane by ID
556    pub fn get_pane_mut(&mut self, id: PaneId) -> Option<&mut Pane> {
557        self.root.as_mut()?.find_pane_mut(id)
558    }
559
560    /// Get all panes
561    pub fn all_panes(&self) -> Vec<&Pane> {
562        self.root
563            .as_ref()
564            .map(|r| r.all_panes())
565            .unwrap_or_default()
566    }
567
568    /// Get all panes mutably
569    pub fn all_panes_mut(&mut self) -> Vec<&mut Pane> {
570        self.root
571            .as_mut()
572            .map(|r| r.all_panes_mut())
573            .unwrap_or_default()
574    }
575
576    /// Collect current per-pane background settings for config persistence
577    ///
578    /// Returns a `Vec<PaneBackgroundConfig>` containing only panes that have
579    /// a custom background image set. The `index` field corresponds to the
580    /// pane's position in the tree traversal order.
581    pub fn collect_pane_backgrounds(&self) -> Vec<PaneBackgroundConfig> {
582        self.all_panes()
583            .iter()
584            .enumerate()
585            .filter_map(|(index, pane)| {
586                pane.background
587                    .image_path
588                    .as_ref()
589                    .map(|path| PaneBackgroundConfig {
590                        index,
591                        image: path.clone(),
592                        mode: pane.background.mode,
593                        opacity: pane.background.opacity,
594                        darken: pane.background.darken,
595                    })
596            })
597            .collect()
598    }
599
600    /// Get the number of panes
601    pub fn pane_count(&self) -> usize {
602        self.root.as_ref().map(|r| r.pane_count()).unwrap_or(0)
603    }
604
605    /// Check if there are multiple panes
606    pub fn has_multiple_panes(&self) -> bool {
607        self.pane_count() > 1
608    }
609
610    /// Set the total bounds available for panes and recalculate layout
611    pub fn set_bounds(&mut self, bounds: PaneBounds) {
612        self.total_bounds = bounds;
613        self.recalculate_bounds();
614    }
615
616    /// Recalculate bounds for all panes
617    pub fn recalculate_bounds(&mut self) {
618        if let Some(ref mut root) = self.root {
619            root.calculate_bounds(self.total_bounds, self.divider_width);
620        }
621    }
622
623    /// Resize all pane terminals to match their current bounds
624    ///
625    /// This should be called after bounds are updated (split, resize, window resize)
626    /// to ensure each PTY is sized correctly for its pane area.
627    pub fn resize_all_terminals(&self, cell_width: f32, cell_height: f32) {
628        self.resize_all_terminals_with_padding(cell_width, cell_height, 0.0, 0.0);
629    }
630
631    /// Resize all terminal PTYs to match their pane bounds, accounting for padding.
632    ///
633    /// The padding reduces the content area where text is rendered, so terminals
634    /// should be sized for the padded (smaller) area to avoid content being cut off.
635    ///
636    /// `height_offset` is an additional height reduction (e.g., pane title bar height)
637    /// subtracted once from each pane's content height.
638    pub fn resize_all_terminals_with_padding(
639        &self,
640        cell_width: f32,
641        cell_height: f32,
642        padding: f32,
643        height_offset: f32,
644    ) {
645        if let Some(ref root) = self.root {
646            for pane in root.all_panes() {
647                // Calculate content size (bounds minus padding on each side, minus title bar)
648                let content_width = (pane.bounds.width - padding * 2.0).max(cell_width);
649                let content_height =
650                    (pane.bounds.height - padding * 2.0 - height_offset).max(cell_height);
651
652                let cols = (content_width / cell_width).floor() as usize;
653                let rows = (content_height / cell_height).floor() as usize;
654
655                pane.resize_terminal(cols.max(1), rows.max(1));
656            }
657        }
658    }
659
660    /// Set the divider width
661    pub fn set_divider_width(&mut self, width: f32) {
662        self.divider_width = width;
663        self.recalculate_bounds();
664    }
665
666    /// Get the divider width
667    pub fn divider_width(&self) -> f32 {
668        self.divider_width
669    }
670
671    /// Get the hit detection padding (extra area around divider for easier grabbing)
672    pub fn divider_hit_padding(&self) -> f32 {
673        (self.divider_hit_width - self.divider_width).max(0.0) / 2.0
674    }
675
676    /// Resize a split by adjusting its ratio
677    ///
678    /// `pane_id`: The pane whose adjacent split should be resized
679    /// `delta`: Amount to adjust the ratio (-1.0 to 1.0)
680    pub fn resize_split(&mut self, pane_id: PaneId, delta: f32) {
681        if let Some(ref mut root) = self.root {
682            Self::adjust_split_ratio(root, pane_id, delta);
683            self.recalculate_bounds();
684        }
685    }
686
687    /// Recursively find and adjust the split ratio for a pane
688    fn adjust_split_ratio(node: &mut PaneNode, target_id: PaneId, delta: f32) -> bool {
689        match node {
690            PaneNode::Leaf(_) => false,
691            PaneNode::Split {
692                ratio,
693                first,
694                second,
695                ..
696            } => {
697                // Check if target is in first child
698                if first.all_pane_ids().contains(&target_id) {
699                    // Try to find in nested splits first
700                    if Self::adjust_split_ratio(first, target_id, delta) {
701                        return true;
702                    }
703                    // Adjust this split's ratio (making first child larger/smaller)
704                    *ratio = (*ratio + delta).clamp(0.1, 0.9);
705                    return true;
706                }
707
708                // Check if target is in second child
709                if second.all_pane_ids().contains(&target_id) {
710                    // Try to find in nested splits first
711                    if Self::adjust_split_ratio(second, target_id, delta) {
712                        return true;
713                    }
714                    // Adjust this split's ratio (making second child larger/smaller)
715                    *ratio = (*ratio - delta).clamp(0.1, 0.9);
716                    return true;
717                }
718
719                false
720            }
721        }
722    }
723
724    /// Get access to the root node (for rendering)
725    pub fn root(&self) -> Option<&PaneNode> {
726        self.root.as_ref()
727    }
728
729    /// Get mutable access to the root node
730    pub fn root_mut(&mut self) -> Option<&mut PaneNode> {
731        self.root.as_mut()
732    }
733
734    /// Get all divider rectangles in the pane tree
735    pub fn get_dividers(&self) -> Vec<DividerRect> {
736        self.root
737            .as_ref()
738            .map(|r| r.collect_dividers(self.total_bounds, self.divider_width))
739            .unwrap_or_default()
740    }
741
742    /// Find a divider at the given position
743    ///
744    /// Returns the index of the divider if found, with optional padding for easier grabbing
745    pub fn find_divider_at(&self, x: f32, y: f32, padding: f32) -> Option<usize> {
746        let dividers = self.get_dividers();
747        for (i, divider) in dividers.iter().enumerate() {
748            if divider.contains(x, y, padding) {
749                return Some(i);
750            }
751        }
752        None
753    }
754
755    /// Check if a position is on a divider
756    pub fn is_on_divider(&self, x: f32, y: f32) -> bool {
757        let padding = (self.divider_hit_width - self.divider_width).max(0.0) / 2.0;
758        self.find_divider_at(x, y, padding).is_some()
759    }
760
761    /// Set the divider hit width
762    pub fn set_divider_hit_width(&mut self, width: f32) {
763        self.divider_hit_width = width;
764    }
765
766    /// Get the divider at an index
767    pub fn get_divider(&self, index: usize) -> Option<DividerRect> {
768        self.get_dividers().get(index).copied()
769    }
770
771    /// Resize by dragging a divider to a new position
772    ///
773    /// `divider_index`: Which divider is being dragged
774    /// `new_position`: New mouse position (x for vertical, y for horizontal dividers)
775    pub fn drag_divider(&mut self, divider_index: usize, new_x: f32, new_y: f32) {
776        // Get the divider info first
777        let dividers = self.get_dividers();
778        if let Some(divider) = dividers.get(divider_index) {
779            // Find the split node that owns this divider and update its ratio
780            if let Some(ref mut root) = self.root {
781                let mut divider_count = 0;
782                Self::update_divider_ratio(
783                    root,
784                    divider_index,
785                    &mut divider_count,
786                    divider.is_horizontal,
787                    new_x,
788                    new_y,
789                    self.total_bounds,
790                    self.divider_width,
791                );
792                self.recalculate_bounds();
793            }
794        }
795    }
796
797    /// Recursively find and update the split ratio for a divider
798    #[allow(clippy::only_used_in_recursion, clippy::too_many_arguments)]
799    fn update_divider_ratio(
800        node: &mut PaneNode,
801        target_index: usize,
802        current_index: &mut usize,
803        is_horizontal: bool,
804        new_x: f32,
805        new_y: f32,
806        bounds: PaneBounds,
807        divider_width: f32,
808    ) -> bool {
809        match node {
810            PaneNode::Leaf(_) => false,
811            PaneNode::Split {
812                direction,
813                ratio,
814                first,
815                second,
816            } => {
817                // Check if this is the target divider
818                if *current_index == target_index {
819                    // Calculate new ratio based on mouse position
820                    let new_ratio = match direction {
821                        SplitDirection::Horizontal => {
822                            // Horizontal split: mouse Y position determines ratio
823                            ((new_y - bounds.y) / bounds.height).clamp(0.1, 0.9)
824                        }
825                        SplitDirection::Vertical => {
826                            // Vertical split: mouse X position determines ratio
827                            ((new_x - bounds.x) / bounds.width).clamp(0.1, 0.9)
828                        }
829                    };
830                    *ratio = new_ratio;
831                    return true;
832                }
833                *current_index += 1;
834
835                // Calculate child bounds to recurse
836                let (first_bounds, second_bounds) = match direction {
837                    SplitDirection::Horizontal => {
838                        let first_height = (bounds.height - divider_width) * *ratio;
839                        let second_height = bounds.height - first_height - divider_width;
840                        (
841                            PaneBounds::new(bounds.x, bounds.y, bounds.width, first_height),
842                            PaneBounds::new(
843                                bounds.x,
844                                bounds.y + first_height + divider_width,
845                                bounds.width,
846                                second_height,
847                            ),
848                        )
849                    }
850                    SplitDirection::Vertical => {
851                        let first_width = (bounds.width - divider_width) * *ratio;
852                        let second_width = bounds.width - first_width - divider_width;
853                        (
854                            PaneBounds::new(bounds.x, bounds.y, first_width, bounds.height),
855                            PaneBounds::new(
856                                bounds.x + first_width + divider_width,
857                                bounds.y,
858                                second_width,
859                                bounds.height,
860                            ),
861                        )
862                    }
863                };
864
865                // Try children
866                if Self::update_divider_ratio(
867                    first,
868                    target_index,
869                    current_index,
870                    is_horizontal,
871                    new_x,
872                    new_y,
873                    first_bounds,
874                    divider_width,
875                ) {
876                    return true;
877                }
878                Self::update_divider_ratio(
879                    second,
880                    target_index,
881                    current_index,
882                    is_horizontal,
883                    new_x,
884                    new_y,
885                    second_bounds,
886                    divider_width,
887                )
888            }
889        }
890    }
891
892    // =========================================================================
893    // Session Restore
894    // =========================================================================
895
896    /// Build a pane tree from a saved session layout
897    ///
898    /// Recursively constructs live `PaneNode` tree from a `SessionPaneNode`,
899    /// creating new terminal panes for each leaf. If a leaf's CWD no longer
900    /// exists, falls back to `$HOME`.
901    pub fn build_from_layout(
902        &mut self,
903        layout: &SessionPaneNode,
904        config: &Config,
905        runtime: Arc<Runtime>,
906    ) -> Result<()> {
907        let root = self.build_node_from_layout(layout, config, runtime)?;
908        let first_id = root.all_pane_ids().first().copied();
909        self.root = Some(root);
910        self.focused_pane_id = first_id;
911        self.recalculate_bounds();
912
913        // Apply per-pane backgrounds from config to restored panes
914        let panes = self.all_panes_mut();
915        for (index, pane) in panes.into_iter().enumerate() {
916            if let Some((image_path, mode, opacity, darken)) = config.get_pane_background(index) {
917                pane.set_background(crate::pane::PaneBackground {
918                    image_path: Some(image_path),
919                    mode,
920                    opacity,
921                    darken,
922                });
923            }
924        }
925
926        Ok(())
927    }
928
929    /// Recursively build a PaneNode from a SessionPaneNode
930    fn build_node_from_layout(
931        &mut self,
932        layout: &SessionPaneNode,
933        config: &Config,
934        runtime: Arc<Runtime>,
935    ) -> Result<PaneNode> {
936        match layout {
937            SessionPaneNode::Leaf { cwd } => {
938                let id = self.next_pane_id;
939                self.next_pane_id += 1;
940
941                let validated_cwd = crate::session::restore::validate_cwd(cwd);
942                let pane = Pane::new(id, config, runtime, validated_cwd)?;
943                Ok(PaneNode::leaf(pane))
944            }
945            SessionPaneNode::Split {
946                direction,
947                ratio,
948                first,
949                second,
950            } => {
951                let first_node = self.build_node_from_layout(first, config, runtime.clone())?;
952                let second_node = self.build_node_from_layout(second, config, runtime)?;
953                Ok(PaneNode::split(*direction, *ratio, first_node, second_node))
954            }
955        }
956    }
957
958    // =========================================================================
959    // tmux Layout Integration
960    // =========================================================================
961
962    /// Set the pane tree from a tmux layout
963    ///
964    /// This replaces the entire pane tree with one constructed from the tmux layout.
965    /// Returns a mapping of tmux pane IDs to native pane IDs.
966    ///
967    /// # Arguments
968    /// * `layout` - The parsed tmux layout
969    /// * `config` - Configuration for creating panes
970    /// * `runtime` - Async runtime for pane tasks
971    pub fn set_from_tmux_layout(
972        &mut self,
973        layout: &TmuxLayout,
974        config: &Config,
975        runtime: Arc<Runtime>,
976    ) -> Result<HashMap<TmuxPaneId, PaneId>> {
977        let mut pane_mappings = HashMap::new();
978
979        // Convert the tmux layout to our pane tree
980        let new_root =
981            self.convert_layout_node(&layout.root, config, runtime.clone(), &mut pane_mappings)?;
982
983        // Replace the root
984        self.root = Some(new_root);
985
986        // Set focus to the first pane in the mapping
987        if let Some(first_native_id) = pane_mappings.values().next() {
988            self.focused_pane_id = Some(*first_native_id);
989        }
990
991        // Update next_pane_id to avoid conflicts
992        if let Some(max_id) = pane_mappings.values().max() {
993            self.next_pane_id = max_id + 1;
994        }
995
996        // Recalculate bounds
997        self.recalculate_bounds();
998
999        log::info!(
1000            "Set pane tree from tmux layout: {} panes",
1001            pane_mappings.len()
1002        );
1003
1004        Ok(pane_mappings)
1005    }
1006
1007    /// Rebuild the pane tree from a tmux layout, preserving existing pane terminals
1008    ///
1009    /// This is called when panes are added or the layout structure changes.
1010    /// It rebuilds the entire tree structure to match the tmux layout while
1011    /// reusing existing Pane objects to preserve their terminal state.
1012    ///
1013    /// # Arguments
1014    /// * `layout` - The parsed tmux layout
1015    /// * `existing_mappings` - Map from tmux pane ID to native pane ID for panes to preserve
1016    /// * `new_tmux_panes` - List of new tmux pane IDs that need new Pane objects
1017    /// * `config` - Configuration for creating new panes
1018    /// * `runtime` - Async runtime for new pane tasks
1019    ///
1020    /// # Returns
1021    /// Updated mapping of tmux pane IDs to native pane IDs
1022    pub fn rebuild_from_tmux_layout(
1023        &mut self,
1024        layout: &TmuxLayout,
1025        existing_mappings: &HashMap<TmuxPaneId, PaneId>,
1026        new_tmux_panes: &[TmuxPaneId],
1027        config: &Config,
1028        runtime: Arc<Runtime>,
1029    ) -> Result<HashMap<TmuxPaneId, PaneId>> {
1030        // Extract all existing panes from the current tree
1031        let mut existing_panes: HashMap<PaneId, Pane> = HashMap::new();
1032        if let Some(root) = self.root.take() {
1033            Self::extract_panes_from_node(root, &mut existing_panes);
1034        }
1035
1036        log::debug!(
1037            "Rebuilding layout: extracted {} existing panes, expecting {} new tmux panes",
1038            existing_panes.len(),
1039            new_tmux_panes.len()
1040        );
1041
1042        // Build the new tree structure from the tmux layout
1043        let mut new_mappings = HashMap::new();
1044        let new_root = self.rebuild_layout_node(
1045            &layout.root,
1046            existing_mappings,
1047            new_tmux_panes,
1048            &mut existing_panes,
1049            config,
1050            runtime.clone(),
1051            &mut new_mappings,
1052        )?;
1053
1054        // Replace the root
1055        self.root = Some(new_root);
1056
1057        // Set focus to the first pane if not already set
1058        if self.focused_pane_id.is_none()
1059            && let Some(first_native_id) = new_mappings.values().next()
1060        {
1061            self.focused_pane_id = Some(*first_native_id);
1062        }
1063
1064        // Update next_pane_id to avoid conflicts
1065        if let Some(max_id) = new_mappings.values().max()
1066            && *max_id >= self.next_pane_id
1067        {
1068            self.next_pane_id = max_id + 1;
1069        }
1070
1071        // Recalculate bounds
1072        self.recalculate_bounds();
1073
1074        log::info!(
1075            "Rebuilt pane tree from tmux layout: {} panes",
1076            new_mappings.len()
1077        );
1078
1079        Ok(new_mappings)
1080    }
1081
1082    /// Extract all panes from a node into a map
1083    fn extract_panes_from_node(node: PaneNode, panes: &mut HashMap<PaneId, Pane>) {
1084        match node {
1085            PaneNode::Leaf(pane) => {
1086                let pane = *pane; // Unbox the pane
1087                panes.insert(pane.id, pane);
1088            }
1089            PaneNode::Split { first, second, .. } => {
1090                Self::extract_panes_from_node(*first, panes);
1091                Self::extract_panes_from_node(*second, panes);
1092            }
1093        }
1094    }
1095
1096    /// Rebuild a layout node, reusing existing panes where possible
1097    #[allow(clippy::too_many_arguments)]
1098    fn rebuild_layout_node(
1099        &mut self,
1100        node: &LayoutNode,
1101        existing_mappings: &HashMap<TmuxPaneId, PaneId>,
1102        new_tmux_panes: &[TmuxPaneId],
1103        existing_panes: &mut HashMap<PaneId, Pane>,
1104        config: &Config,
1105        runtime: Arc<Runtime>,
1106        new_mappings: &mut HashMap<TmuxPaneId, PaneId>,
1107    ) -> Result<PaneNode> {
1108        match node {
1109            LayoutNode::Pane { id: tmux_id, .. } => {
1110                // Check if this is an existing pane we can reuse
1111                if let Some(&native_id) = existing_mappings.get(tmux_id)
1112                    && let Some(pane) = existing_panes.remove(&native_id)
1113                {
1114                    log::debug!(
1115                        "Reusing existing pane {} for tmux pane %{}",
1116                        native_id,
1117                        tmux_id
1118                    );
1119                    new_mappings.insert(*tmux_id, native_id);
1120                    return Ok(PaneNode::leaf(pane));
1121                }
1122
1123                // This is a new pane - create it
1124                if new_tmux_panes.contains(tmux_id) {
1125                    let native_id = self.next_pane_id;
1126                    self.next_pane_id += 1;
1127
1128                    let pane = Pane::new_for_tmux(native_id, config, runtime)?;
1129                    log::debug!("Created new pane {} for tmux pane %{}", native_id, tmux_id);
1130                    new_mappings.insert(*tmux_id, native_id);
1131                    return Ok(PaneNode::leaf(pane));
1132                }
1133
1134                // Fallback - create a new pane (shouldn't happen normally)
1135                log::warn!("Unexpected tmux pane %{} - creating new pane", tmux_id);
1136                let native_id = self.next_pane_id;
1137                self.next_pane_id += 1;
1138                let pane = Pane::new_for_tmux(native_id, config, runtime)?;
1139                new_mappings.insert(*tmux_id, native_id);
1140                Ok(PaneNode::leaf(pane))
1141            }
1142
1143            LayoutNode::VerticalSplit {
1144                width, children, ..
1145            } => {
1146                // Vertical split = panes side by side
1147                self.rebuild_multi_split_to_binary(
1148                    children,
1149                    SplitDirection::Vertical,
1150                    *width,
1151                    existing_mappings,
1152                    new_tmux_panes,
1153                    existing_panes,
1154                    config,
1155                    runtime,
1156                    new_mappings,
1157                )
1158            }
1159
1160            LayoutNode::HorizontalSplit {
1161                height, children, ..
1162            } => {
1163                // Horizontal split = panes stacked
1164                self.rebuild_multi_split_to_binary(
1165                    children,
1166                    SplitDirection::Horizontal,
1167                    *height,
1168                    existing_mappings,
1169                    new_tmux_panes,
1170                    existing_panes,
1171                    config,
1172                    runtime,
1173                    new_mappings,
1174                )
1175            }
1176        }
1177    }
1178
1179    /// Rebuild multi-child split to binary, reusing existing panes
1180    #[allow(clippy::too_many_arguments)]
1181    fn rebuild_multi_split_to_binary(
1182        &mut self,
1183        children: &[LayoutNode],
1184        direction: SplitDirection,
1185        total_size: usize,
1186        existing_mappings: &HashMap<TmuxPaneId, PaneId>,
1187        new_tmux_panes: &[TmuxPaneId],
1188        existing_panes: &mut HashMap<PaneId, Pane>,
1189        config: &Config,
1190        runtime: Arc<Runtime>,
1191        new_mappings: &mut HashMap<TmuxPaneId, PaneId>,
1192    ) -> Result<PaneNode> {
1193        if children.is_empty() {
1194            anyhow::bail!("Empty children list in tmux layout");
1195        }
1196
1197        if children.len() == 1 {
1198            return self.rebuild_layout_node(
1199                &children[0],
1200                existing_mappings,
1201                new_tmux_panes,
1202                existing_panes,
1203                config,
1204                runtime,
1205                new_mappings,
1206            );
1207        }
1208
1209        // Calculate the size of the first child for the ratio
1210        let first_size = Self::get_node_size(&children[0], direction);
1211        let ratio = (first_size as f32) / (total_size as f32);
1212
1213        // Rebuild the first child
1214        let first = self.rebuild_layout_node(
1215            &children[0],
1216            existing_mappings,
1217            new_tmux_panes,
1218            existing_panes,
1219            config,
1220            runtime.clone(),
1221            new_mappings,
1222        )?;
1223
1224        // Calculate remaining size
1225        let remaining_size = total_size.saturating_sub(first_size + 1);
1226
1227        // Rebuild the rest recursively
1228        let second = if children.len() == 2 {
1229            self.rebuild_layout_node(
1230                &children[1],
1231                existing_mappings,
1232                new_tmux_panes,
1233                existing_panes,
1234                config,
1235                runtime,
1236                new_mappings,
1237            )?
1238        } else {
1239            self.rebuild_remaining_children(
1240                &children[1..],
1241                direction,
1242                remaining_size,
1243                existing_mappings,
1244                new_tmux_panes,
1245                existing_panes,
1246                config,
1247                runtime,
1248                new_mappings,
1249            )?
1250        };
1251
1252        Ok(PaneNode::split(direction, ratio, first, second))
1253    }
1254
1255    /// Rebuild remaining children into nested binary splits
1256    #[allow(clippy::too_many_arguments)]
1257    fn rebuild_remaining_children(
1258        &mut self,
1259        children: &[LayoutNode],
1260        direction: SplitDirection,
1261        total_size: usize,
1262        existing_mappings: &HashMap<TmuxPaneId, PaneId>,
1263        new_tmux_panes: &[TmuxPaneId],
1264        existing_panes: &mut HashMap<PaneId, Pane>,
1265        config: &Config,
1266        runtime: Arc<Runtime>,
1267        new_mappings: &mut HashMap<TmuxPaneId, PaneId>,
1268    ) -> Result<PaneNode> {
1269        if children.len() == 1 {
1270            return self.rebuild_layout_node(
1271                &children[0],
1272                existing_mappings,
1273                new_tmux_panes,
1274                existing_panes,
1275                config,
1276                runtime,
1277                new_mappings,
1278            );
1279        }
1280
1281        let first_size = Self::get_node_size(&children[0], direction);
1282        let ratio = (first_size as f32) / (total_size as f32);
1283
1284        let first = self.rebuild_layout_node(
1285            &children[0],
1286            existing_mappings,
1287            new_tmux_panes,
1288            existing_panes,
1289            config,
1290            runtime.clone(),
1291            new_mappings,
1292        )?;
1293
1294        let remaining_size = total_size.saturating_sub(first_size + 1);
1295        let second = self.rebuild_remaining_children(
1296            &children[1..],
1297            direction,
1298            remaining_size,
1299            existing_mappings,
1300            new_tmux_panes,
1301            existing_panes,
1302            config,
1303            runtime,
1304            new_mappings,
1305        )?;
1306
1307        Ok(PaneNode::split(direction, ratio, first, second))
1308    }
1309
1310    /// Convert a tmux LayoutNode to a PaneNode
1311    fn convert_layout_node(
1312        &mut self,
1313        node: &LayoutNode,
1314        config: &Config,
1315        runtime: Arc<Runtime>,
1316        mappings: &mut HashMap<TmuxPaneId, PaneId>,
1317    ) -> Result<PaneNode> {
1318        match node {
1319            LayoutNode::Pane {
1320                id: tmux_id,
1321                width: _,
1322                height: _,
1323                x: _,
1324                y: _,
1325            } => {
1326                // Create a native pane for this tmux pane
1327                let native_id = self.next_pane_id;
1328                self.next_pane_id += 1;
1329
1330                let pane = Pane::new_for_tmux(native_id, config, runtime)?;
1331
1332                // Record the mapping
1333                mappings.insert(*tmux_id, native_id);
1334
1335                log::debug!(
1336                    "Created native pane {} for tmux pane %{}",
1337                    native_id,
1338                    tmux_id
1339                );
1340
1341                Ok(PaneNode::leaf(pane))
1342            }
1343
1344            LayoutNode::VerticalSplit {
1345                width,
1346                height: _,
1347                x: _,
1348                y: _,
1349                children,
1350            } => {
1351                // Vertical split = panes side by side
1352                self.convert_multi_split_to_binary(
1353                    children,
1354                    SplitDirection::Vertical,
1355                    *width,
1356                    config,
1357                    runtime,
1358                    mappings,
1359                )
1360            }
1361
1362            LayoutNode::HorizontalSplit {
1363                width: _,
1364                height,
1365                x: _,
1366                y: _,
1367                children,
1368            } => {
1369                // Horizontal split = panes stacked
1370                self.convert_multi_split_to_binary(
1371                    children,
1372                    SplitDirection::Horizontal,
1373                    *height,
1374                    config,
1375                    runtime,
1376                    mappings,
1377                )
1378            }
1379        }
1380    }
1381
1382    /// Convert a multi-child split to nested binary splits
1383    ///
1384    /// tmux layouts can have multiple children in a single split,
1385    /// but our pane tree uses binary splits. We convert like this:
1386    /// [A, B, C] -> Split(A, Split(B, C))
1387    fn convert_multi_split_to_binary(
1388        &mut self,
1389        children: &[LayoutNode],
1390        direction: SplitDirection,
1391        total_size: usize,
1392        config: &Config,
1393        runtime: Arc<Runtime>,
1394        mappings: &mut HashMap<TmuxPaneId, PaneId>,
1395    ) -> Result<PaneNode> {
1396        if children.is_empty() {
1397            anyhow::bail!("Empty children list in tmux layout");
1398        }
1399
1400        if children.len() == 1 {
1401            // Single child - just convert it directly
1402            return self.convert_layout_node(&children[0], config, runtime, mappings);
1403        }
1404
1405        // Calculate the size of the first child for the ratio
1406        let first_size = Self::get_node_size(&children[0], direction);
1407        let ratio = (first_size as f32) / (total_size as f32);
1408
1409        // Convert the first child
1410        let first = self.convert_layout_node(&children[0], config, runtime.clone(), mappings)?;
1411
1412        // Calculate remaining size for the rest
1413        let remaining_size = total_size.saturating_sub(first_size + 1); // -1 for divider
1414
1415        // Convert the rest recursively
1416        let second = if children.len() == 2 {
1417            self.convert_layout_node(&children[1], config, runtime, mappings)?
1418        } else {
1419            // Create a synthetic split node for the remaining children
1420            let remaining = &children[1..];
1421            self.convert_remaining_children(
1422                remaining,
1423                direction,
1424                remaining_size,
1425                config,
1426                runtime,
1427                mappings,
1428            )?
1429        };
1430
1431        Ok(PaneNode::split(direction, ratio, first, second))
1432    }
1433
1434    /// Convert remaining children into nested binary splits
1435    fn convert_remaining_children(
1436        &mut self,
1437        children: &[LayoutNode],
1438        direction: SplitDirection,
1439        total_size: usize,
1440        config: &Config,
1441        runtime: Arc<Runtime>,
1442        mappings: &mut HashMap<TmuxPaneId, PaneId>,
1443    ) -> Result<PaneNode> {
1444        if children.len() == 1 {
1445            return self.convert_layout_node(&children[0], config, runtime, mappings);
1446        }
1447
1448        let first_size = Self::get_node_size(&children[0], direction);
1449        let ratio = (first_size as f32) / (total_size as f32);
1450
1451        let first = self.convert_layout_node(&children[0], config, runtime.clone(), mappings)?;
1452
1453        let remaining_size = total_size.saturating_sub(first_size + 1);
1454        let second = self.convert_remaining_children(
1455            &children[1..],
1456            direction,
1457            remaining_size,
1458            config,
1459            runtime,
1460            mappings,
1461        )?;
1462
1463        Ok(PaneNode::split(direction, ratio, first, second))
1464    }
1465
1466    /// Get the size of a node in the given direction
1467    fn get_node_size(node: &LayoutNode, direction: SplitDirection) -> usize {
1468        match node {
1469            LayoutNode::Pane { width, height, .. } => match direction {
1470                SplitDirection::Vertical => *width,
1471                SplitDirection::Horizontal => *height,
1472            },
1473            LayoutNode::VerticalSplit { width, height, .. }
1474            | LayoutNode::HorizontalSplit { width, height, .. } => match direction {
1475                SplitDirection::Vertical => *width,
1476                SplitDirection::Horizontal => *height,
1477            },
1478        }
1479    }
1480
1481    /// Update the layout structure (ratios) from a tmux layout without recreating terminals
1482    ///
1483    /// This is called when the tmux pane IDs haven't changed but the layout
1484    /// dimensions have (e.g., due to resize or another client connecting).
1485    /// It updates the split ratios in our pane tree to match the tmux layout.
1486    pub fn update_layout_from_tmux(
1487        &mut self,
1488        layout: &TmuxLayout,
1489        pane_mappings: &HashMap<TmuxPaneId, PaneId>,
1490    ) {
1491        // Calculate ratios from the tmux layout and update our tree
1492        if let Some(ref mut root) = self.root {
1493            Self::update_node_from_tmux_layout(root, &layout.root, pane_mappings);
1494        }
1495
1496        log::debug!(
1497            "Updated pane layout ratios from tmux layout ({} panes)",
1498            pane_mappings.len()
1499        );
1500    }
1501
1502    /// Recursively update a pane node's ratios and directions from tmux layout
1503    fn update_node_from_tmux_layout(
1504        node: &mut PaneNode,
1505        tmux_node: &LayoutNode,
1506        pane_mappings: &HashMap<TmuxPaneId, PaneId>,
1507    ) {
1508        match (node, tmux_node) {
1509            // Leaf nodes - nothing to update for ratios
1510            (PaneNode::Leaf(_), LayoutNode::Pane { .. }) => {}
1511
1512            // Split node with VerticalSplit layout (panes side by side)
1513            (
1514                PaneNode::Split {
1515                    direction,
1516                    ratio,
1517                    first,
1518                    second,
1519                },
1520                LayoutNode::VerticalSplit {
1521                    width, children, ..
1522                },
1523            ) if !children.is_empty() => {
1524                // Update direction to match tmux layout
1525                if *direction != SplitDirection::Vertical {
1526                    log::debug!(
1527                        "Updating split direction from {:?} to Vertical to match tmux layout",
1528                        direction
1529                    );
1530                    *direction = SplitDirection::Vertical;
1531                }
1532
1533                // Calculate ratio from first child's width vs total
1534                let first_size = Self::get_node_size(&children[0], SplitDirection::Vertical);
1535                let total_size = *width;
1536                if total_size > 0 {
1537                    *ratio = (first_size as f32) / (total_size as f32);
1538                    log::debug!(
1539                        "Updated vertical split ratio: {} / {} = {}",
1540                        first_size,
1541                        total_size,
1542                        *ratio
1543                    );
1544                }
1545
1546                // Recursively update first child
1547                Self::update_node_from_tmux_layout(first, &children[0], pane_mappings);
1548
1549                // For the second child, handle multi-child case
1550                if children.len() == 2 {
1551                    Self::update_node_from_tmux_layout(second, &children[1], pane_mappings);
1552                } else if children.len() > 2 {
1553                    // Our tree is binary but tmux has N children
1554                    // The second child is a nested split containing children[1..]
1555                    // Recursively update with remaining children treated as a nested split
1556                    Self::update_nested_split(
1557                        second,
1558                        &children[1..],
1559                        SplitDirection::Vertical,
1560                        pane_mappings,
1561                    );
1562                }
1563            }
1564
1565            // Split node with HorizontalSplit layout (panes stacked)
1566            (
1567                PaneNode::Split {
1568                    direction,
1569                    ratio,
1570                    first,
1571                    second,
1572                },
1573                LayoutNode::HorizontalSplit {
1574                    height, children, ..
1575                },
1576            ) if !children.is_empty() => {
1577                // Update direction to match tmux layout
1578                if *direction != SplitDirection::Horizontal {
1579                    log::debug!(
1580                        "Updating split direction from {:?} to Horizontal to match tmux layout",
1581                        direction
1582                    );
1583                    *direction = SplitDirection::Horizontal;
1584                }
1585
1586                // Calculate ratio from first child's height vs total
1587                let first_size = Self::get_node_size(&children[0], SplitDirection::Horizontal);
1588                let total_size = *height;
1589                if total_size > 0 {
1590                    *ratio = (first_size as f32) / (total_size as f32);
1591                    log::debug!(
1592                        "Updated horizontal split ratio: {} / {} = {}",
1593                        first_size,
1594                        total_size,
1595                        *ratio
1596                    );
1597                }
1598
1599                // Recursively update first child
1600                Self::update_node_from_tmux_layout(first, &children[0], pane_mappings);
1601
1602                // For the second child, handle multi-child case
1603                if children.len() == 2 {
1604                    Self::update_node_from_tmux_layout(second, &children[1], pane_mappings);
1605                } else if children.len() > 2 {
1606                    // Our tree is binary but tmux has N children
1607                    Self::update_nested_split(
1608                        second,
1609                        &children[1..],
1610                        SplitDirection::Horizontal,
1611                        pane_mappings,
1612                    );
1613                }
1614            }
1615
1616            // Mismatched structure - log and skip
1617            _ => {
1618                log::debug!("Layout structure mismatch during update - skipping ratio update");
1619            }
1620        }
1621    }
1622
1623    /// Update a nested binary split from a flat list of tmux children
1624    fn update_nested_split(
1625        node: &mut PaneNode,
1626        children: &[LayoutNode],
1627        direction: SplitDirection,
1628        pane_mappings: &HashMap<TmuxPaneId, PaneId>,
1629    ) {
1630        if children.is_empty() {
1631            return;
1632        }
1633
1634        if children.len() == 1 {
1635            // Single child - update directly
1636            Self::update_node_from_tmux_layout(node, &children[0], pane_mappings);
1637            return;
1638        }
1639
1640        // Multiple children - node should be a split
1641        if let PaneNode::Split {
1642            ratio,
1643            first,
1644            second,
1645            ..
1646        } = node
1647        {
1648            // Calculate ratio: first child size vs remaining total
1649            let first_size = Self::get_node_size(&children[0], direction);
1650            let remaining_size: usize = children
1651                .iter()
1652                .map(|c| Self::get_node_size(c, direction))
1653                .sum();
1654
1655            if remaining_size > 0 {
1656                *ratio = (first_size as f32) / (remaining_size as f32);
1657                log::debug!(
1658                    "Updated nested split ratio: {} / {} = {}",
1659                    first_size,
1660                    remaining_size,
1661                    *ratio
1662                );
1663            }
1664
1665            // Update first child
1666            Self::update_node_from_tmux_layout(first, &children[0], pane_mappings);
1667
1668            // Recurse for remaining children
1669            Self::update_nested_split(second, &children[1..], direction, pane_mappings);
1670        } else {
1671            // Node isn't a split but we expected one - update as single
1672            Self::update_node_from_tmux_layout(node, &children[0], pane_mappings);
1673        }
1674    }
1675
1676    /// Update an existing pane tree to match a new tmux layout
1677    ///
1678    /// This tries to preserve existing panes where possible and only
1679    /// creates/destroys panes as needed.
1680    ///
1681    /// Returns updated mappings (Some = new mappings, None = no changes needed)
1682    pub fn update_from_tmux_layout(
1683        &mut self,
1684        layout: &TmuxLayout,
1685        existing_mappings: &HashMap<TmuxPaneId, PaneId>,
1686        config: &Config,
1687        runtime: Arc<Runtime>,
1688    ) -> Result<Option<HashMap<TmuxPaneId, PaneId>>> {
1689        // Get the pane IDs from the new layout
1690        let new_pane_ids: std::collections::HashSet<_> = layout.pane_ids().into_iter().collect();
1691
1692        // Check if the pane set has changed
1693        let existing_tmux_ids: std::collections::HashSet<_> =
1694            existing_mappings.keys().copied().collect();
1695
1696        if new_pane_ids == existing_tmux_ids {
1697            // Same panes, just need to update the layout structure
1698            // For now, we rebuild completely since layout changes are complex
1699            // A future optimization could preserve terminals and just restructure
1700            log::debug!("tmux layout changed but same panes - rebuilding structure");
1701        }
1702
1703        // For now, always rebuild the tree completely
1704        // A more sophisticated implementation would try to preserve terminals
1705        let new_mappings = self.set_from_tmux_layout(layout, config, runtime)?;
1706        Ok(Some(new_mappings))
1707    }
1708}
1709
1710impl Default for PaneManager {
1711    fn default() -> Self {
1712        Self::new()
1713    }
1714}
1715
1716/// Result of attempting to remove a pane from the tree
1717enum RemoveResult {
1718    /// Pane was removed, returning the new subtree (or None if empty)
1719    Removed(Option<PaneNode>),
1720    /// Pane was not found, returning the original tree
1721    NotFound(PaneNode),
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726    use super::*;
1727
1728    // Note: Full tests would require mocking TerminalManager
1729    // These are placeholder tests for the manager logic
1730
1731    #[test]
1732    fn test_pane_manager_new() {
1733        let manager = PaneManager::new();
1734        assert!(manager.root.is_none());
1735        assert_eq!(manager.pane_count(), 0);
1736        assert!(!manager.has_multiple_panes());
1737    }
1738}