Skip to main content

fresh/view/
split.rs

1/// Split view system for displaying multiple buffers simultaneously
2///
3/// Design Philosophy (following Emacs model):
4/// - A split is a tree structure: either a leaf (single buffer) or a node (horizontal/vertical split)
5/// - Each split has a fixed size (in percentage or absolute lines/columns)
6/// - Splits can be nested arbitrarily deep
7/// - Only one split is "active" at a time (receives input)
8/// - Splits can display the same buffer multiple times (useful for viewing different parts)
9///
10/// Example split layouts:
11/// ```text
12/// ┌────────────────────┐      ┌──────────┬─────────┐
13/// │                    │      │          │         │
14/// │   Single buffer    │      │  Buffer  │ Buffer  │
15/// │                    │      │    A     │    B    │
16/// └────────────────────┘      └──────────┴─────────┘
17///   (no split)                  (vertical split)
18///
19/// ┌────────────────────┐      ┌──────────┬─────────┐
20/// │     Buffer A       │      │          │ Buffer C│
21/// ├────────────────────┤      │  Buffer  ├─────────┤
22/// │     Buffer B       │      │    A     │ Buffer D│
23/// └────────────────────┘      └──────────┴─────────┘
24///  (horizontal split)          (mixed splits)
25/// ```
26use crate::model::buffer::Buffer;
27use crate::model::cursor::Cursors;
28use crate::model::event::{BufferId, ContainerId, LeafId, SplitDirection, SplitId};
29use crate::model::marker::MarkerList;
30use crate::view::folding::FoldManager;
31use crate::view::ui::view_pipeline::Layout;
32use crate::view::viewport::Viewport;
33use crate::{services::plugins::api::ViewTransformPayload, state::ViewMode};
34use ratatui::layout::Rect;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37
38/// A tab target — what a tab entry in a split's tab bar points to.
39///
40/// The tab bar contains a mix of regular buffer tabs and group tabs.
41/// Group tabs point to a `SplitNode::Grouped` node by its `LeafId`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum TabTarget {
44    /// A regular buffer tab
45    Buffer(BufferId),
46    /// A buffer group tab — points to a `SplitNode::Grouped` node's `split_id`
47    Group(LeafId),
48}
49
50impl TabTarget {
51    pub fn as_buffer(self) -> Option<BufferId> {
52        match self {
53            Self::Buffer(id) => Some(id),
54            Self::Group(_) => None,
55        }
56    }
57
58    pub fn as_group(self) -> Option<LeafId> {
59        match self {
60            Self::Buffer(_) => None,
61            Self::Group(id) => Some(id),
62        }
63    }
64}
65
66/// Role tag for special-purpose leaves in the split tree.
67///
68/// At most one leaf in the tree carries any given role (this is the
69/// invariant that makes "tagged singleton dock" work — see
70/// `docs/internal/tui-editor-layout-design.md`, Section 2).
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub enum SplitRole {
73    /// The Utility Dock — diagnostics, search-replace results, terminal,
74    /// quickfix, and other panel-like utilities all swap into this leaf
75    /// instead of spawning new splits.
76    UtilityDock,
77}
78
79/// A node in the split tree
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub enum SplitNode {
82    /// Leaf node: displays a single buffer
83    Leaf {
84        /// Which buffer to display
85        buffer_id: BufferId,
86        /// Unique ID for this split pane
87        split_id: LeafId,
88        /// Optional role tag (e.g. UtilityDock). At most one leaf in
89        /// the tree may carry any given role; the dispatcher routes
90        /// tagged buffer creation to the existing tagged leaf.
91        #[serde(default)]
92        role: Option<SplitRole>,
93    },
94    /// Internal node: contains two child splits
95    Split {
96        /// Direction of the split
97        direction: SplitDirection,
98        /// First child (top or left)
99        first: Box<Self>,
100        /// Second child (bottom or right)
101        second: Box<Self>,
102        /// Size ratio (0.0 to 1.0) - how much space the first child gets
103        /// 0.5 = equal split, 0.3 = first gets 30%, etc.
104        ratio: f32,
105        /// Unique ID for this split container
106        split_id: ContainerId,
107        /// If set, first child gets exactly this many rows/cols instead of using ratio
108        #[serde(default)]
109        fixed_first: Option<u16>,
110        /// If set, second child gets exactly this many rows/cols instead of using ratio
111        #[serde(default)]
112        fixed_second: Option<u16>,
113    },
114    /// A grouped subtree that appears as a single tab entry in its parent
115    /// split's tab bar. When that tab is active, the subtree is expanded
116    /// and rendered inside the parent split's content area. When inactive,
117    /// the node is skipped during rect computation.
118    Grouped {
119        /// Unique ID used as a tab target (see `TabTarget::Group`).
120        /// Behaves like a `LeafId` — identifies this node uniquely.
121        split_id: LeafId,
122        /// Display name shown in the tab bar
123        name: String,
124        /// The nested layout to render when this tab is active
125        layout: Box<Self>,
126        /// The preferred active leaf within the layout (for focus when activating)
127        active_inner_leaf: LeafId,
128    },
129}
130
131/// Per-buffer view state within a split.
132///
133/// Each buffer opened in a split gets its own `BufferViewState` stored in the
134/// split's `keyed_states` map. This ensures that switching buffers within a split
135/// preserves cursor position, scroll state, view mode, and compose settings
136/// independently for each buffer.
137#[derive(Debug)]
138pub struct BufferViewState {
139    /// Independent cursor set (supports multi-cursor)
140    pub cursors: Cursors,
141
142    /// Independent scroll position
143    pub viewport: Viewport,
144
145    /// View mode (Source/Compose) for this buffer in this split
146    pub view_mode: ViewMode,
147
148    /// Optional compose width for centering/wrapping
149    pub compose_width: Option<u16>,
150
151    /// Column guides (e.g., tables)
152    pub compose_column_guides: Option<Vec<u16>>,
153
154    /// Vertical ruler positions (initialized from config, mutable per-buffer)
155    pub rulers: Vec<usize>,
156
157    /// Per-split line number visibility.
158    /// This is the single source of truth for whether line numbers are shown
159    /// in this split. Initialized from config when the split is created.
160    /// Compose mode forces this to false; leaving compose restores from config.
161    pub show_line_numbers: bool,
162
163    /// Per-split current line highlight visibility.
164    /// When true, the line containing the cursor gets a distinct background color.
165    /// Initialized from config when the split is created.
166    pub highlight_current_line: bool,
167
168    /// Optional view transform payload
169    pub view_transform: Option<ViewTransformPayload>,
170
171    /// True when the buffer was edited since the last view_transform_request hook fired.
172    /// While true, incoming SubmitViewTransform commands are rejected as stale
173    /// (their tokens have source_offsets from before the edit).
174    pub view_transform_stale: bool,
175
176    /// Plugin-managed state (arbitrary key-value pairs).
177    /// Plugins can store per-buffer-per-split state here via the `setViewState`/`getViewState` API.
178    /// Persisted across sessions via workspace serialization.
179    pub plugin_state: std::collections::HashMap<String, serde_json::Value>,
180
181    /// Collapsed folding ranges for this buffer/view.
182    pub folds: FoldManager,
183}
184
185impl BufferViewState {
186    /// Resolve fold ranges and ensure the primary cursor is visible.
187    ///
188    /// This is the preferred entry point for all non-rendering callers — it
189    /// resolves hidden fold byte ranges from the marker list and passes them
190    /// to `viewport.ensure_visible` so that line counting skips folded lines.
191    pub fn ensure_cursor_visible(&mut self, buffer: &mut Buffer, marker_list: &MarkerList) {
192        let hidden: Vec<(usize, usize)> = self
193            .folds
194            .resolved_ranges(buffer, marker_list)
195            .into_iter()
196            .map(|r| (r.start_byte, r.end_byte))
197            .collect();
198        let cursor = *self.cursors.primary();
199        self.viewport.ensure_visible(buffer, &cursor, &hidden);
200    }
201
202    /// Create a new buffer view state with defaults
203    pub fn new(width: u16, height: u16) -> Self {
204        Self {
205            cursors: Cursors::new(),
206            viewport: Viewport::new(width, height),
207            view_mode: ViewMode::Source,
208            compose_width: None,
209            compose_column_guides: None,
210            rulers: Vec::new(),
211            show_line_numbers: true,
212            highlight_current_line: true,
213            view_transform: None,
214            view_transform_stale: false,
215            plugin_state: std::collections::HashMap::new(),
216            folds: FoldManager::new(),
217        }
218    }
219
220    /// Apply editor config defaults for display settings.
221    ///
222    /// Sets `show_line_numbers`, `highlight_current_line`, `line_wrap`,
223    /// `wrap_column`, and `rulers` from the given config values. Call this after
224    /// creating a new `BufferViewState` (via `new()` or `ensure_buffer_state()`)
225    /// to ensure the view respects the user's settings.
226    pub fn apply_config_defaults(
227        &mut self,
228        line_numbers: bool,
229        highlight_current_line: bool,
230        line_wrap: bool,
231        wrap_indent: bool,
232        wrap_column: Option<usize>,
233        rulers: Vec<usize>,
234        scroll_offset: usize,
235    ) {
236        self.show_line_numbers = line_numbers;
237        self.highlight_current_line = highlight_current_line;
238        self.viewport.line_wrap_enabled = line_wrap;
239        self.viewport.wrap_indent = wrap_indent;
240        self.viewport.wrap_column = wrap_column;
241        self.rulers = rulers;
242        self.viewport.set_scroll_offset(scroll_offset);
243    }
244
245    /// Activate page view (compose mode) with an optional page width.
246    ///
247    /// This sets the view mode to Compose, disables builtin line wrap
248    /// (the compose plugin handles wrapping), hides line numbers,
249    /// and optionally sets the compose width for centering.
250    pub fn activate_page_view(&mut self, page_width: Option<usize>) {
251        self.view_mode = ViewMode::PageView;
252        self.show_line_numbers = false;
253        self.viewport.line_wrap_enabled = false;
254        if let Some(width) = page_width {
255            self.compose_width = Some(width as u16);
256        }
257    }
258}
259
260impl Clone for BufferViewState {
261    fn clone(&self) -> Self {
262        Self {
263            cursors: self.cursors.clone(),
264            viewport: self.viewport.clone(),
265            view_mode: self.view_mode.clone(),
266            compose_width: self.compose_width,
267            compose_column_guides: self.compose_column_guides.clone(),
268            rulers: self.rulers.clone(),
269            show_line_numbers: self.show_line_numbers,
270            highlight_current_line: self.highlight_current_line,
271            view_transform: self.view_transform.clone(),
272            view_transform_stale: self.view_transform_stale,
273            plugin_state: self.plugin_state.clone(),
274            // Fold markers are per-view; clones start with no folded ranges.
275            folds: FoldManager::new(),
276        }
277    }
278}
279
280/// Per-split view state (independent of buffer content)
281///
282/// Following the Emacs model where each window (split) has its own:
283/// - Point (cursor position) - independent per split
284/// - Window-start (scroll position) - independent per split
285/// - Tabs (open buffers) - independent per split
286///
287/// Buffer-specific state (cursors, viewport, view_mode, compose settings) is stored
288/// in the `keyed_states` map, keyed by `BufferId`. The active buffer's state is
289/// accessible via `Deref`/`DerefMut` (so `vs.cursors` transparently accesses the
290/// active buffer's cursors), or explicitly via `active_state()`/`active_state_mut()`.
291#[derive(Debug, Clone)]
292pub struct SplitViewState {
293    /// Which buffer is currently active in this split
294    pub active_buffer: BufferId,
295
296    /// Per-buffer view state map. The active buffer always has an entry.
297    pub keyed_states: HashMap<BufferId, BufferViewState>,
298
299    /// List of tab targets open in this split's tab bar (in order).
300    /// Each entry is either a regular buffer or a grouped subtree.
301    /// The currently displayed target is tracked by `active_buffer`
302    /// (for buffer tabs) or by walking the tree for the active leaf
303    /// (for group tabs).
304    pub open_buffers: Vec<TabTarget>,
305
306    /// Horizontal scroll offset for the tabs in this split
307    pub tab_scroll_offset: usize,
308
309    /// Computed layout for this view (from view_transform or base tokens)
310    /// This is View state - each split has its own Layout
311    pub layout: Option<Layout>,
312
313    /// Whether the layout needs to be rebuilt (buffer changed, transform changed, etc.)
314    pub layout_dirty: bool,
315
316    /// Focus history stack for this split (most recent at end).
317    /// Tracks both buffer tabs and group tabs so that "Switch to Previous
318    /// Tab" and close-buffer replacement both work across tab types.
319    pub focus_history: Vec<TabTarget>,
320
321    /// Sync group ID for synchronized scrolling
322    /// Splits with the same sync_group will scroll together
323    pub sync_group: Option<u32>,
324
325    /// When set, this split renders a composite view (e.g., side-by-side diff).
326    /// The split's buffer_id is the focused source buffer, but rendering uses
327    /// the composite layout. This makes the source buffer the "active buffer"
328    /// so normal keybindings work directly.
329    pub composite_view: Option<BufferId>,
330
331    /// When true, suppress per-split chrome (tab bar, close/maximize buttons).
332    /// Used for splits within a buffer group where the group provides its own tab.
333    pub suppress_chrome: bool,
334
335    /// When true, hide tilde markers (~) for empty rows in this split.
336    /// Used for panels where empty space should be blank, not marked.
337    pub hide_tilde: bool,
338
339    /// When `Some(leaf_id)`, the currently "active tab" of this split is the
340    /// buffer group identified by `leaf_id` (i.e., `TabTarget::Group(leaf_id)`).
341    /// When `None`, the active tab is a regular buffer (`TabTarget::Buffer(active_buffer)`).
342    pub active_group_tab: Option<LeafId>,
343
344    /// When a group tab is active, this tracks which inner leaf inside the
345    /// group's subtree has keyboard focus.
346    pub focused_group_leaf: Option<LeafId>,
347}
348
349impl std::ops::Deref for SplitViewState {
350    type Target = BufferViewState;
351
352    fn deref(&self) -> &BufferViewState {
353        self.active_state()
354    }
355}
356
357impl std::ops::DerefMut for SplitViewState {
358    fn deref_mut(&mut self) -> &mut BufferViewState {
359        self.active_state_mut()
360    }
361}
362
363impl SplitViewState {
364    /// Create a new split view state with an initial buffer open
365    pub fn with_buffer(width: u16, height: u16, buffer_id: BufferId) -> Self {
366        let buf_state = BufferViewState::new(width, height);
367        let mut keyed_states = HashMap::new();
368        keyed_states.insert(buffer_id, buf_state);
369        Self {
370            active_buffer: buffer_id,
371            keyed_states,
372            open_buffers: vec![TabTarget::Buffer(buffer_id)],
373            tab_scroll_offset: 0,
374            layout: None,
375            layout_dirty: true,
376            focus_history: Vec::new(),
377            sync_group: None,
378            composite_view: None,
379            suppress_chrome: false,
380            hide_tilde: false,
381            active_group_tab: None,
382            focused_group_leaf: None,
383        }
384    }
385
386    /// Get the active buffer's view state
387    pub fn active_state(&self) -> &BufferViewState {
388        self.keyed_states
389            .get(&self.active_buffer)
390            .expect("active_buffer must always have an entry in keyed_states")
391    }
392
393    /// Get a mutable reference to the active buffer's view state
394    pub fn active_state_mut(&mut self) -> &mut BufferViewState {
395        self.keyed_states
396            .get_mut(&self.active_buffer)
397            .expect("active_buffer must always have an entry in keyed_states")
398    }
399
400    /// Switch the active buffer in this split.
401    ///
402    /// If the new buffer has a saved state in `keyed_states`, it is restored.
403    /// Otherwise a default `BufferViewState` is created with the split's current
404    /// viewport dimensions.
405    pub fn switch_buffer(&mut self, new_buffer_id: BufferId) {
406        if new_buffer_id == self.active_buffer {
407            return;
408        }
409        // Ensure the new buffer has keyed state (create default if first time)
410        if !self.keyed_states.contains_key(&new_buffer_id) {
411            let active = self.active_state();
412            let width = active.viewport.width;
413            let height = active.viewport.height;
414            self.keyed_states
415                .insert(new_buffer_id, BufferViewState::new(width, height));
416        }
417        self.active_buffer = new_buffer_id;
418        // Invalidate layout since we're now showing different buffer content
419        self.layout_dirty = true;
420    }
421
422    /// Get the view state for a specific buffer (if it exists)
423    pub fn buffer_state(&self, buffer_id: BufferId) -> Option<&BufferViewState> {
424        self.keyed_states.get(&buffer_id)
425    }
426
427    /// Get a mutable reference to the view state for a specific buffer (if it exists)
428    pub fn buffer_state_mut(&mut self, buffer_id: BufferId) -> Option<&mut BufferViewState> {
429        self.keyed_states.get_mut(&buffer_id)
430    }
431
432    /// Ensure a buffer has keyed state, creating a default if needed.
433    /// Returns a mutable reference to the buffer's view state.
434    pub fn ensure_buffer_state(&mut self, buffer_id: BufferId) -> &mut BufferViewState {
435        let (width, height) = {
436            let active = self.active_state();
437            (active.viewport.width, active.viewport.height)
438        };
439        self.keyed_states
440            .entry(buffer_id)
441            .or_insert_with(|| BufferViewState::new(width, height))
442    }
443
444    /// Remove keyed state for a buffer (when buffer is closed from this split)
445    pub fn remove_buffer_state(&mut self, buffer_id: BufferId) {
446        if buffer_id != self.active_buffer {
447            self.keyed_states.remove(&buffer_id);
448        }
449    }
450
451    /// Mark layout as needing rebuild (call after buffer changes)
452    pub fn invalidate_layout(&mut self) {
453        self.layout_dirty = true;
454    }
455
456    /// Ensure layout is valid, rebuilding if needed.
457    /// Returns the Layout - never returns None. Following VSCode's ViewModel pattern.
458    ///
459    /// # Arguments
460    /// * `tokens` - ViewTokenWire array (from view_transform or built from buffer)
461    /// * `source_range` - The byte range this layout covers
462    /// * `tab_size` - Tab width for rendering
463    pub fn ensure_layout(
464        &mut self,
465        tokens: &[fresh_core::api::ViewTokenWire],
466        source_range: std::ops::Range<usize>,
467        tab_size: usize,
468    ) -> &Layout {
469        if self.layout.is_none() || self.layout_dirty {
470            self.layout = Some(Layout::from_tokens(tokens, source_range, tab_size));
471            self.layout_dirty = false;
472        }
473        self.layout.as_ref().unwrap()
474    }
475
476    /// Get the current layout if it exists and is valid
477    pub fn get_layout(&self) -> Option<&Layout> {
478        if self.layout_dirty {
479            None
480        } else {
481            self.layout.as_ref()
482        }
483    }
484
485    /// Add a buffer to this split's tabs (if not already present)
486    pub fn add_buffer(&mut self, buffer_id: BufferId) {
487        if !self.has_buffer(buffer_id) {
488            self.open_buffers.push(TabTarget::Buffer(buffer_id));
489        }
490    }
491
492    /// Remove a buffer from this split's tabs and clean up its keyed state
493    pub fn remove_buffer(&mut self, buffer_id: BufferId) {
494        self.open_buffers
495            .retain(|t| *t != TabTarget::Buffer(buffer_id));
496        // Clean up keyed state (but never remove the active buffer's state)
497        if buffer_id != self.active_buffer {
498            self.keyed_states.remove(&buffer_id);
499        }
500    }
501
502    /// Check if a buffer is open in this split
503    pub fn has_buffer(&self, buffer_id: BufferId) -> bool {
504        self.open_buffers.contains(&TabTarget::Buffer(buffer_id))
505    }
506
507    /// Add a group tab to this split's tabs (if not already present)
508    pub fn add_group(&mut self, leaf_id: LeafId) {
509        if !self.has_group(leaf_id) {
510            self.open_buffers.push(TabTarget::Group(leaf_id));
511        }
512    }
513
514    /// Remove a group tab from this split's tabs
515    pub fn remove_group(&mut self, leaf_id: LeafId) {
516        self.open_buffers
517            .retain(|t| *t != TabTarget::Group(leaf_id));
518    }
519
520    /// Check if a group tab is open in this split
521    pub fn has_group(&self, leaf_id: LeafId) -> bool {
522        self.open_buffers.contains(&TabTarget::Group(leaf_id))
523    }
524
525    /// Iterate over only the buffer-tab ids in open_buffers (skipping groups).
526    pub fn buffer_tab_ids(&self) -> impl Iterator<Item = BufferId> + '_ {
527        self.open_buffers.iter().filter_map(|t| t.as_buffer())
528    }
529
530    /// Collect buffer-tab ids as a Vec<BufferId> (skipping groups).
531    /// Convenience for call sites that need ownership / indexing.
532    pub fn buffer_tab_ids_vec(&self) -> Vec<BufferId> {
533        self.buffer_tab_ids().collect()
534    }
535
536    /// Count only buffer tabs (ignoring group tabs).
537    pub fn buffer_tab_count(&self) -> usize {
538        self.open_buffers
539            .iter()
540            .filter(|t| matches!(t, TabTarget::Buffer(_)))
541            .count()
542    }
543
544    /// Return the effective active tab target for this split.
545    /// If a group tab is marked active, returns `TabTarget::Group`. Otherwise
546    /// returns `TabTarget::Buffer(active_buffer)`.
547    pub fn active_target(&self) -> TabTarget {
548        match self.active_group_tab {
549            Some(leaf_id) => TabTarget::Group(leaf_id),
550            None => TabTarget::Buffer(self.active_buffer),
551        }
552    }
553
554    /// Switch the active tab to a regular buffer target. Clears any
555    /// active group tab marker.
556    pub fn set_active_buffer_tab(&mut self, buffer_id: BufferId) {
557        self.active_group_tab = None;
558        self.focused_group_leaf = None;
559        self.switch_buffer(buffer_id);
560    }
561
562    /// Switch the active tab to a group target.
563    pub fn set_active_group_tab(&mut self, leaf_id: LeafId) {
564        self.active_group_tab = Some(leaf_id);
565    }
566
567    /// Push a tab target to the focus history (LRU-style).
568    /// If the target is already in history, it's moved to the end.
569    pub fn push_focus(&mut self, target: TabTarget) {
570        self.focus_history.retain(|t| *t != target);
571        self.focus_history.push(target);
572        if self.focus_history.len() > 50 {
573            self.focus_history.remove(0);
574        }
575    }
576
577    /// Get the most recently focused tab target (without removing it)
578    pub fn previous_tab(&self) -> Option<TabTarget> {
579        self.focus_history.last().copied()
580    }
581
582    /// Remove a buffer from the focus history (called when buffer is closed)
583    pub fn remove_from_history(&mut self, buffer_id: BufferId) {
584        self.focus_history
585            .retain(|t| *t != TabTarget::Buffer(buffer_id));
586    }
587
588    /// Remove a group from the focus history (called when group is closed)
589    pub fn remove_group_from_history(&mut self, leaf_id: LeafId) {
590        self.focus_history
591            .retain(|t| *t != TabTarget::Group(leaf_id));
592    }
593}
594
595impl SplitNode {
596    /// Create a new leaf node
597    pub fn leaf(buffer_id: BufferId, split_id: SplitId) -> Self {
598        Self::Leaf {
599            buffer_id,
600            split_id: LeafId(split_id),
601            role: None,
602        }
603    }
604
605    /// Create a new leaf node with a role tag.
606    pub fn leaf_with_role(buffer_id: BufferId, split_id: SplitId, role: SplitRole) -> Self {
607        Self::Leaf {
608            buffer_id,
609            split_id: LeafId(split_id),
610            role: Some(role),
611        }
612    }
613
614    /// Get this leaf's role, if any.
615    pub fn role(&self) -> Option<SplitRole> {
616        match self {
617            Self::Leaf { role, .. } => *role,
618            _ => None,
619        }
620    }
621
622    /// Set this leaf's role. No-op for non-leaf nodes.
623    pub fn set_role(&mut self, new_role: Option<SplitRole>) {
624        if let Self::Leaf { role, .. } = self {
625            *role = new_role;
626        }
627    }
628
629    /// Create a new split node with two children
630    pub fn split(
631        direction: SplitDirection,
632        first: SplitNode,
633        second: SplitNode,
634        ratio: f32,
635        split_id: SplitId,
636    ) -> Self {
637        SplitNode::Split {
638            direction,
639            first: Box::new(first),
640            second: Box::new(second),
641            ratio: ratio.clamp(0.1, 0.9), // Prevent extreme ratios
642            split_id: ContainerId(split_id),
643            fixed_first: None,
644            fixed_second: None,
645        }
646    }
647
648    /// Get the split ID for this node
649    pub fn id(&self) -> SplitId {
650        match self {
651            Self::Leaf { split_id, .. } => split_id.0,
652            Self::Split { split_id, .. } => split_id.0,
653            Self::Grouped { split_id, .. } => split_id.0,
654        }
655    }
656
657    /// Get the buffer ID if this is a leaf node
658    pub fn buffer_id(&self) -> Option<BufferId> {
659        match self {
660            Self::Leaf { buffer_id, .. } => Some(*buffer_id),
661            Self::Split { .. } | Self::Grouped { .. } => None,
662        }
663    }
664
665    /// Find a split by ID (returns mutable reference).
666    /// Grouped nodes are found by their `split_id`, and their inner
667    /// layout is searched as well.
668    pub fn find_mut(&mut self, target_id: SplitId) -> Option<&mut Self> {
669        if self.id() == target_id {
670            return Some(self);
671        }
672
673        match self {
674            Self::Leaf { .. } => None,
675            Self::Split { first, second, .. } => first
676                .find_mut(target_id)
677                .or_else(|| second.find_mut(target_id)),
678            Self::Grouped { layout, .. } => layout.find_mut(target_id),
679        }
680    }
681
682    /// Find a split by ID (returns immutable reference).
683    /// Grouped nodes are found by their `split_id`, and their inner
684    /// layout is searched as well.
685    pub fn find(&self, target_id: SplitId) -> Option<&Self> {
686        if self.id() == target_id {
687            return Some(self);
688        }
689
690        match self {
691            Self::Leaf { .. } => None,
692            Self::Split { first, second, .. } => {
693                first.find(target_id).or_else(|| second.find(target_id))
694            }
695            Self::Grouped { layout, .. } => layout.find(target_id),
696        }
697    }
698
699    /// Find the parent container of a given split node.
700    /// For a node inside a Grouped subtree, returns the container within
701    /// the subtree (not the Grouped node itself).
702    pub fn parent_container_of(&self, target_id: SplitId) -> Option<ContainerId> {
703        match self {
704            Self::Leaf { .. } => None,
705            Self::Split {
706                split_id,
707                first,
708                second,
709                ..
710            } => {
711                if first.id() == target_id || second.id() == target_id {
712                    Some(*split_id)
713                } else {
714                    first
715                        .parent_container_of(target_id)
716                        .or_else(|| second.parent_container_of(target_id))
717                }
718            }
719            Self::Grouped { layout, .. } => layout.parent_container_of(target_id),
720        }
721    }
722
723    /// Find the Grouped ancestor node that contains a given target id (by walking
724    /// into Grouped subtrees). Returns the Grouped node's own `split_id` if found.
725    pub fn grouped_ancestor_of(&self, target_id: SplitId) -> Option<LeafId> {
726        match self {
727            Self::Leaf { .. } => None,
728            Self::Split { first, second, .. } => first
729                .grouped_ancestor_of(target_id)
730                .or_else(|| second.grouped_ancestor_of(target_id)),
731            Self::Grouped {
732                split_id, layout, ..
733            } => {
734                if layout.find(target_id).is_some() {
735                    Some(*split_id)
736                } else {
737                    layout.grouped_ancestor_of(target_id)
738                }
739            }
740        }
741    }
742
743    /// Find the Grouped node whose `split_id` matches `target`. Returns
744    /// a reference to the Grouped node (or None).
745    pub fn find_grouped(&self, target: LeafId) -> Option<&Self> {
746        match self {
747            Self::Leaf { .. } => None,
748            Self::Split { first, second, .. } => first
749                .find_grouped(target)
750                .or_else(|| second.find_grouped(target)),
751            Self::Grouped {
752                split_id, layout, ..
753            } => {
754                if *split_id == target {
755                    Some(self)
756                } else {
757                    layout.find_grouped(target)
758                }
759            }
760        }
761    }
762
763    /// Get all leaf nodes (buffer views) with their rectangles.
764    ///
765    /// Grouped nodes always recurse into their inner layout — the layout's
766    /// leaves get the full rect that would have been given to the Grouped
767    /// node. Visibility (which group is "active") is applied elsewhere.
768    pub fn get_leaves_with_rects(&self, rect: Rect) -> Vec<(LeafId, BufferId, Rect)> {
769        match self {
770            Self::Leaf {
771                buffer_id,
772                split_id,
773                ..
774            } => {
775                vec![(*split_id, *buffer_id, rect)]
776            }
777            Self::Split {
778                direction,
779                first,
780                second,
781                ratio,
782                fixed_first,
783                fixed_second,
784                ..
785            } => {
786                let (first_rect, second_rect) =
787                    split_rect_ext(rect, *direction, *ratio, *fixed_first, *fixed_second);
788                let mut leaves = first.get_leaves_with_rects(first_rect);
789                leaves.extend(second.get_leaves_with_rects(second_rect));
790                leaves
791            }
792            Self::Grouped { layout, .. } => layout.get_leaves_with_rects(rect),
793        }
794    }
795
796    /// Walk the tree using an "active group" predicate. For each Grouped node
797    /// encountered, the predicate is called with the Grouped node's split_id;
798    /// if it returns `true`, the node's layout is recursed into (with the
799    /// Grouped node's rect). If `false`, the Grouped node and its subtree are
800    /// skipped entirely (not rendered).
801    pub fn get_visible_leaves_with_rects<F>(
802        &self,
803        rect: Rect,
804        is_group_active: &F,
805    ) -> Vec<(LeafId, BufferId, Rect)>
806    where
807        F: Fn(LeafId) -> bool,
808    {
809        match self {
810            Self::Leaf {
811                buffer_id,
812                split_id,
813                ..
814            } => {
815                vec![(*split_id, *buffer_id, rect)]
816            }
817            Self::Split {
818                direction,
819                first,
820                second,
821                ratio,
822                fixed_first,
823                fixed_second,
824                ..
825            } => {
826                let (first_rect, second_rect) =
827                    split_rect_ext(rect, *direction, *ratio, *fixed_first, *fixed_second);
828                let mut leaves = first.get_visible_leaves_with_rects(first_rect, is_group_active);
829                leaves.extend(second.get_visible_leaves_with_rects(second_rect, is_group_active));
830                leaves
831            }
832            Self::Grouped {
833                split_id, layout, ..
834            } => {
835                if is_group_active(*split_id) {
836                    layout.get_visible_leaves_with_rects(rect, is_group_active)
837                } else {
838                    Vec::new()
839                }
840            }
841        }
842    }
843
844    /// Get all split separator lines (for rendering borders)
845    /// Returns (direction, x, y, length) tuples
846    pub fn get_separators(&self, rect: Rect) -> Vec<(SplitDirection, u16, u16, u16)> {
847        self.get_separators_with_ids(rect)
848            .into_iter()
849            .map(|(_, dir, x, y, len)| (dir, x, y, len))
850            .collect()
851    }
852
853    /// Get all split separator lines with their split IDs (for mouse hit testing)
854    /// Returns (split_id, direction, x, y, length) tuples
855    pub fn get_separators_with_ids(
856        &self,
857        rect: Rect,
858    ) -> Vec<(ContainerId, SplitDirection, u16, u16, u16)> {
859        match self {
860            Self::Leaf { .. } => vec![],
861            Self::Grouped { layout, .. } => layout.get_separators_with_ids(rect),
862            Self::Split {
863                direction,
864                first,
865                second,
866                ratio,
867                split_id,
868                fixed_first,
869                fixed_second,
870            } => {
871                let (first_rect, second_rect) =
872                    split_rect_ext(rect, *direction, *ratio, *fixed_first, *fixed_second);
873                let mut separators = Vec::new();
874
875                // Add separator for this split (in the 1-char gap between first and second)
876                match direction {
877                    SplitDirection::Horizontal => {
878                        // Horizontal split: separator line is between first and second
879                        // y position is at the end of first rect (the gap line)
880                        separators.push((
881                            *split_id,
882                            SplitDirection::Horizontal,
883                            rect.x,
884                            first_rect.y + first_rect.height,
885                            rect.width,
886                        ));
887                    }
888                    SplitDirection::Vertical => {
889                        // Vertical split: separator line is between first and second
890                        // x position is at the end of first rect (the gap column)
891                        separators.push((
892                            *split_id,
893                            SplitDirection::Vertical,
894                            first_rect.x + first_rect.width,
895                            rect.y,
896                            rect.height,
897                        ));
898                    }
899                }
900
901                // Recursively get separators from children
902                separators.extend(first.get_separators_with_ids(first_rect));
903                separators.extend(second.get_separators_with_ids(second_rect));
904                separators
905            }
906        }
907    }
908
909    /// Collect all split IDs in the tree
910    pub fn all_split_ids(&self) -> Vec<SplitId> {
911        let mut ids = vec![self.id()];
912        match self {
913            Self::Leaf { .. } => ids,
914            Self::Split { first, second, .. } => {
915                ids.extend(first.all_split_ids());
916                ids.extend(second.all_split_ids());
917                ids
918            }
919            Self::Grouped { layout, .. } => {
920                ids.extend(layout.all_split_ids());
921                ids
922            }
923        }
924    }
925
926    /// Collect only leaf split IDs (visible buffer splits, not container nodes).
927    /// For Grouped nodes, returns the inner layout's leaves.
928    pub fn leaf_split_ids(&self) -> Vec<LeafId> {
929        match self {
930            Self::Leaf { split_id, .. } => vec![*split_id],
931            Self::Split { first, second, .. } => {
932                let mut ids = first.leaf_split_ids();
933                ids.extend(second.leaf_split_ids());
934                ids
935            }
936            Self::Grouped { layout, .. } => layout.leaf_split_ids(),
937        }
938    }
939
940    /// Count the number of leaf nodes (visible buffers).
941    /// Grouped subtrees count their inner leaves.
942    pub fn count_leaves(&self) -> usize {
943        match self {
944            Self::Leaf { .. } => 1,
945            Self::Split { first, second, .. } => first.count_leaves() + second.count_leaves(),
946            Self::Grouped { layout, .. } => layout.count_leaves(),
947        }
948    }
949
950    /// Collect display names for all Grouped nodes in the tree, keyed by
951    /// their LeafId (which is what `TabTarget::Group` points to).
952    pub fn collect_group_names(&self) -> HashMap<LeafId, String> {
953        let mut map = HashMap::new();
954        self.collect_group_names_into(&mut map);
955        map
956    }
957
958    fn collect_group_names_into(&self, map: &mut HashMap<LeafId, String>) {
959        match self {
960            Self::Leaf { .. } => {}
961            Self::Split { first, second, .. } => {
962                first.collect_group_names_into(map);
963                second.collect_group_names_into(map);
964            }
965            Self::Grouped {
966                split_id,
967                name,
968                layout,
969                ..
970            } => {
971                map.insert(*split_id, name.clone());
972                layout.collect_group_names_into(map);
973            }
974        }
975    }
976}
977
978/// Split a rectangle into two parts based on direction and ratio
979/// Leaves 1 character space for the separator line between splits
980#[cfg(test)]
981fn split_rect(rect: Rect, direction: SplitDirection, ratio: f32) -> (Rect, Rect) {
982    split_rect_ext(rect, direction, ratio, None, None)
983}
984
985fn split_rect_ext(
986    rect: Rect,
987    direction: SplitDirection,
988    ratio: f32,
989    fixed_first: Option<u16>,
990    fixed_second: Option<u16>,
991) -> (Rect, Rect) {
992    match direction {
993        SplitDirection::Horizontal => {
994            // Split into top and bottom, with 1 line for separator
995            let total_height = rect.height.saturating_sub(1); // Reserve 1 line for separator
996            let first_height = if let Some(f) = fixed_first {
997                f.min(total_height)
998            } else if let Some(s) = fixed_second {
999                total_height.saturating_sub(s.min(total_height))
1000            } else {
1001                (total_height as f32 * ratio).round() as u16
1002            };
1003            let second_height = total_height.saturating_sub(first_height);
1004
1005            let first = Rect {
1006                x: rect.x,
1007                y: rect.y,
1008                width: rect.width,
1009                height: first_height,
1010            };
1011
1012            let second = Rect {
1013                x: rect.x,
1014                y: rect.y + first_height + 1, // +1 for separator
1015                width: rect.width,
1016                height: second_height,
1017            };
1018
1019            (first, second)
1020        }
1021        SplitDirection::Vertical => {
1022            // Split into left and right, with 1 column for separator
1023            let total_width = rect.width.saturating_sub(1); // Reserve 1 column for separator
1024            let first_width = if let Some(f) = fixed_first {
1025                f.min(total_width)
1026            } else if let Some(s) = fixed_second {
1027                total_width.saturating_sub(s.min(total_width))
1028            } else {
1029                (total_width as f32 * ratio).round() as u16
1030            };
1031            let second_width = total_width.saturating_sub(first_width);
1032
1033            let first = Rect {
1034                x: rect.x,
1035                y: rect.y,
1036                width: first_width,
1037                height: rect.height,
1038            };
1039
1040            let second = Rect {
1041                x: rect.x + first_width + 1, // +1 for separator
1042                y: rect.y,
1043                width: second_width,
1044                height: rect.height,
1045            };
1046
1047            (first, second)
1048        }
1049    }
1050}
1051
1052/// Manager for the split view system
1053#[derive(Debug)]
1054pub struct SplitManager {
1055    /// Root of the split tree
1056    root: SplitNode,
1057
1058    /// Currently active split (receives input) — always a leaf
1059    active_split: LeafId,
1060
1061    /// Next split ID to assign
1062    next_split_id: usize,
1063
1064    /// Currently maximized split (if any). When set, only this split is visible.
1065    maximized_split: Option<SplitId>,
1066
1067    /// Labels for leaf splits (e.g., "sidebar" to mark managed splits)
1068    labels: HashMap<SplitId, String>,
1069
1070    /// LRU of leaves that have been the active split, oldest first.
1071    /// `set_active_split` pushes the new active and promotes any
1072    /// existing entry; `last_focused_where` lets callers query the
1073    /// history with an arbitrary predicate (e.g. "last leaf without
1074    /// `SplitRole::UtilityDock`" for file-open routing). Stale
1075    /// entries (leaves that have since been closed) are filtered at
1076    /// read time, not eagerly pruned.
1077    focus_history: Vec<LeafId>,
1078}
1079
1080/// Cap on `SplitManager::focus_history` length. Mirrors the same cap
1081/// used by `SplitViewState::focus_history` (per-split tab focus).
1082const FOCUS_HISTORY_CAP: usize = 50;
1083
1084impl SplitManager {
1085    /// Create a new split manager with a single buffer
1086    pub fn new(buffer_id: BufferId) -> Self {
1087        let split_id = SplitId(0);
1088        Self {
1089            root: SplitNode::leaf(buffer_id, split_id),
1090            active_split: LeafId(split_id),
1091            next_split_id: 1,
1092            maximized_split: None,
1093            labels: HashMap::new(),
1094            focus_history: vec![LeafId(split_id)],
1095        }
1096    }
1097
1098    /// Get the root split node
1099    pub fn root(&self) -> &SplitNode {
1100        &self.root
1101    }
1102
1103    /// Allocate a new unique split ID
1104    pub fn allocate_split_id(&mut self) -> SplitId {
1105        let id = SplitId(self.next_split_id);
1106        self.next_split_id += 1;
1107        id
1108    }
1109
1110    /// Replace the root split tree. The new tree must have unique IDs
1111    /// (allocated via `allocate_split_id`). The caller must also provide
1112    /// the new active leaf ID.
1113    pub fn replace_root(&mut self, new_root: SplitNode, new_active: LeafId) {
1114        self.root = new_root;
1115        self.active_split = new_active;
1116        // None of the previously-tracked focus-history ids exist in
1117        // the new tree. Reseed with just the new active.
1118        self.focus_history.clear();
1119        self.focus_history.push(new_active);
1120    }
1121
1122    /// Get the currently active split ID
1123    pub fn active_split(&self) -> LeafId {
1124        self.active_split
1125    }
1126
1127    /// Set the active split (must be a leaf)
1128    pub fn set_active_split(&mut self, split_id: LeafId) -> bool {
1129        // Verify the split exists
1130        if self.root.find(split_id.into()).is_some() {
1131            self.active_split = split_id;
1132            // Promote (or insert) the new active leaf in the focus
1133            // LRU. Same dedup-and-push pattern as
1134            // `SplitViewState::focus_history` for tab focus.
1135            self.focus_history.retain(|leaf| *leaf != split_id);
1136            self.focus_history.push(split_id);
1137            if self.focus_history.len() > FOCUS_HISTORY_CAP {
1138                self.focus_history.remove(0);
1139            }
1140            true
1141        } else {
1142            false
1143        }
1144    }
1145
1146    /// Role of a leaf split, or `None` if the leaf has no role tag or
1147    /// the id doesn't reference a leaf.
1148    pub fn leaf_role(&self, split_id: LeafId) -> Option<SplitRole> {
1149        self.root.find(split_id.into()).and_then(|node| node.role())
1150    }
1151
1152    /// Walk the focus history newest-first and return the first leaf
1153    /// that satisfies `predicate` and still exists in the tree. Stale
1154    /// entries (leaves closed since they were focused) are skipped.
1155    ///
1156    /// Generic by design: callers compose the dock/role/label/buffer
1157    /// rule they care about. File-open routing uses
1158    /// `|leaf| mgr.leaf_role(leaf) != Some(SplitRole::UtilityDock)`;
1159    /// future panel-aware features can pass their own filters
1160    /// without touching this method.
1161    pub fn last_focused_where<F>(&self, mut predicate: F) -> Option<LeafId>
1162    where
1163        F: FnMut(LeafId) -> bool,
1164    {
1165        self.focus_history
1166            .iter()
1167            .rev()
1168            .copied()
1169            .find(|leaf| self.root.find((*leaf).into()).is_some() && predicate(*leaf))
1170    }
1171
1172    /// Get the buffer ID of the active split (if it's a leaf)
1173    pub fn active_buffer_id(&self) -> Option<BufferId> {
1174        self.root
1175            .find(self.active_split.into())
1176            .and_then(|node| node.buffer_id())
1177    }
1178
1179    /// Get the buffer ID for a specific split (if it's a leaf)
1180    pub fn get_buffer_id(&self, split_id: SplitId) -> Option<BufferId> {
1181        self.root.find(split_id).and_then(|node| node.buffer_id())
1182    }
1183
1184    /// Update the buffer ID of the active split
1185    pub fn set_active_buffer_id(&mut self, new_buffer_id: BufferId) -> bool {
1186        if let Some(SplitNode::Leaf { buffer_id, .. }) =
1187            self.root.find_mut(self.active_split.into())
1188        {
1189            *buffer_id = new_buffer_id;
1190            return true;
1191        }
1192        false
1193    }
1194
1195    /// Update the buffer ID of a specific leaf split
1196    pub fn set_split_buffer(&mut self, leaf_id: LeafId, new_buffer_id: BufferId) {
1197        match self.root.find_mut(leaf_id.into()) {
1198            Some(SplitNode::Leaf { buffer_id, .. }) => {
1199                *buffer_id = new_buffer_id;
1200            }
1201            Some(SplitNode::Split { .. }) => {
1202                unreachable!("LeafId {:?} points to a container", leaf_id)
1203            }
1204            Some(SplitNode::Grouped { .. }) => {
1205                unreachable!("LeafId {:?} points to a Grouped node", leaf_id)
1206            }
1207            None => {
1208                unreachable!("LeafId {:?} not found in split tree", leaf_id)
1209            }
1210        }
1211    }
1212
1213    // allocate_split_id is defined as pub earlier in this impl block
1214
1215    /// Split the currently active pane
1216    pub fn split_active(
1217        &mut self,
1218        direction: SplitDirection,
1219        new_buffer_id: BufferId,
1220        ratio: f32,
1221    ) -> Result<LeafId, String> {
1222        self.split_active_positioned(direction, new_buffer_id, ratio, false)
1223    }
1224
1225    /// Split the active pane, placing the new buffer before (left/top) the existing content.
1226    /// `ratio` still controls the first child's proportion of space.
1227    pub fn split_active_before(
1228        &mut self,
1229        direction: SplitDirection,
1230        new_buffer_id: BufferId,
1231        ratio: f32,
1232    ) -> Result<LeafId, String> {
1233        self.split_active_positioned(direction, new_buffer_id, ratio, true)
1234    }
1235
1236    pub fn split_active_positioned(
1237        &mut self,
1238        direction: SplitDirection,
1239        new_buffer_id: BufferId,
1240        ratio: f32,
1241        before: bool,
1242    ) -> Result<LeafId, String> {
1243        let active_id: SplitId = self.active_split.into();
1244
1245        // Find the parent of the active split
1246        let result =
1247            self.replace_split_with_split(active_id, direction, new_buffer_id, ratio, before);
1248
1249        if let Ok(new_split_id) = &result {
1250            // Set the new split as active
1251            self.active_split = *new_split_id;
1252        }
1253        result
1254    }
1255
1256    /// Split the root of the tree (rather than the active leaf), so the
1257    /// new leaf becomes a sibling of the entire existing layout. Used
1258    /// by the Utility Dock so the dock spans the full width below any
1259    /// pre-existing horizontal-axis splits, instead of nesting under
1260    /// whichever pane happened to be active.
1261    ///
1262    /// `ratio` controls the first child's proportion. `before = false`
1263    /// places the new leaf after (right/bottom) the existing root.
1264    pub fn split_root_positioned(
1265        &mut self,
1266        direction: SplitDirection,
1267        new_buffer_id: BufferId,
1268        ratio: f32,
1269        before: bool,
1270    ) -> Result<LeafId, String> {
1271        let root_id = self.root.id();
1272        let result =
1273            self.replace_split_with_split(root_id, direction, new_buffer_id, ratio, before);
1274        if let Ok(new_split_id) = &result {
1275            self.active_split = *new_split_id;
1276        }
1277        result
1278    }
1279
1280    /// Replace a split with a new split container.
1281    /// When `before` is true, the new buffer is placed as the first child (left/top).
1282    fn replace_split_with_split(
1283        &mut self,
1284        target_id: SplitId,
1285        direction: SplitDirection,
1286        new_buffer_id: BufferId,
1287        ratio: f32,
1288        before: bool,
1289    ) -> Result<LeafId, String> {
1290        // Pre-allocate all IDs before any borrowing
1291        let temp_id = self.allocate_split_id();
1292        let new_split_id = self.allocate_split_id();
1293        let new_leaf_id = self.allocate_split_id();
1294
1295        // Special case: if target is root, replace root
1296        if self.root.id() == target_id {
1297            let old_root =
1298                std::mem::replace(&mut self.root, SplitNode::leaf(new_buffer_id, temp_id));
1299            let new_leaf = SplitNode::leaf(new_buffer_id, new_leaf_id);
1300
1301            let (first, second) = if before {
1302                (new_leaf, old_root)
1303            } else {
1304                (old_root, new_leaf)
1305            };
1306
1307            self.root = SplitNode::split(direction, first, second, ratio, new_split_id);
1308
1309            return Ok(LeafId(new_leaf_id));
1310        }
1311
1312        // Find and replace the target node
1313        if let Some(node) = self.root.find_mut(target_id) {
1314            let old_node = std::mem::replace(node, SplitNode::leaf(new_buffer_id, temp_id));
1315            let new_leaf = SplitNode::leaf(new_buffer_id, new_leaf_id);
1316
1317            let (first, second) = if before {
1318                (new_leaf, old_node)
1319            } else {
1320                (old_node, new_leaf)
1321            };
1322
1323            *node = SplitNode::split(direction, first, second, ratio, new_split_id);
1324
1325            Ok(LeafId(new_leaf_id))
1326        } else {
1327            Err(format!("Split {:?} not found", target_id))
1328        }
1329    }
1330
1331    /// Close a split pane (if not the last one)
1332    pub fn close_split(&mut self, split_id: LeafId) -> Result<(), String> {
1333        // Can't close if it's the only split
1334        if self.root.count_leaves() <= 1 {
1335            return Err("Cannot close the last split".to_string());
1336        }
1337
1338        // Can't close if it's the root and root is a leaf
1339        if self.root.id() == split_id.into() && self.root.buffer_id().is_some() {
1340            return Err("Cannot close the only split".to_string());
1341        }
1342
1343        // If the split being closed is maximized, unmaximize first
1344        if self.maximized_split == Some(split_id.into()) {
1345            self.maximized_split = None;
1346        }
1347
1348        // Collect all split IDs that will be removed (the target and its children)
1349        let removed_ids: Vec<SplitId> = self
1350            .root
1351            .find(split_id.into())
1352            .map(|node| node.all_split_ids())
1353            .unwrap_or_default();
1354
1355        // Find the parent of the split to close
1356        // This requires a parent-tracking traversal
1357        let result = self.remove_split_node(split_id.into());
1358
1359        if result.is_ok() {
1360            // Clean up labels for all removed splits
1361            for id in &removed_ids {
1362                self.labels.remove(id);
1363            }
1364
1365            // If we closed the active split, update active_split to another split
1366            if self.active_split == split_id {
1367                let leaf_ids = self.root.leaf_split_ids();
1368                if let Some(&first_leaf) = leaf_ids.first() {
1369                    self.active_split = first_leaf;
1370                }
1371            }
1372        }
1373
1374        result
1375    }
1376
1377    /// Remove a split node from the tree
1378    fn remove_split_node(&mut self, target_id: SplitId) -> Result<(), String> {
1379        // Special case: removing root
1380        if self.root.id() == target_id {
1381            if let SplitNode::Split { first, .. } = &self.root {
1382                // Replace root with the other child
1383                // Choose first child arbitrarily
1384                self.root = (**first).clone();
1385                return Ok(());
1386            }
1387        }
1388
1389        // Recursively find and remove
1390        Self::remove_child_static(&mut self.root, target_id)
1391    }
1392
1393    /// Helper to remove a child from a split node (static to avoid borrow issues)
1394    fn remove_child_static(node: &mut SplitNode, target_id: SplitId) -> Result<(), String> {
1395        match node {
1396            SplitNode::Leaf { .. } => Err("Target not found".to_string()),
1397            SplitNode::Grouped { layout, .. } => Self::remove_child_static(layout, target_id),
1398            SplitNode::Split { first, second, .. } => {
1399                // Check if either child is the target
1400                if first.id() == target_id {
1401                    // Replace this node with the second child
1402                    *node = (**second).clone();
1403                    Ok(())
1404                } else if second.id() == target_id {
1405                    // Replace this node with the first child
1406                    *node = (**first).clone();
1407                    Ok(())
1408                } else {
1409                    // Recurse into children
1410                    Self::remove_child_static(first, target_id)
1411                        .or_else(|_| Self::remove_child_static(second, target_id))
1412                }
1413            }
1414        }
1415    }
1416
1417    /// Remove a Grouped node from the tree by its split_id. Unlike
1418    /// `close_split` which requires a leaf, this removes a whole Grouped
1419    /// subtree (tab) from the split structure. The Grouped node is
1420    /// replaced with... well, nothing — so this can only succeed if the
1421    /// Grouped is inside a Split (so we can replace the Split with its
1422    /// sibling) or if the root itself is the Grouped (which we can't
1423    /// remove without a replacement).
1424    pub fn remove_grouped(&mut self, target: LeafId) -> Result<(), String> {
1425        let target_id: SplitId = target.into();
1426        if self.root.id() == target_id {
1427            return Err("Cannot remove root Grouped node".to_string());
1428        }
1429        Self::remove_child_static(&mut self.root, target_id)
1430    }
1431
1432    /// Adjust the split ratio of a container
1433    pub fn adjust_ratio(&mut self, container_id: ContainerId, delta: f32) {
1434        match self.root.find_mut(container_id.into()) {
1435            Some(SplitNode::Split { ratio, .. }) => {
1436                *ratio = (*ratio + delta).clamp(0.1, 0.9);
1437            }
1438            Some(SplitNode::Leaf { .. }) => {
1439                unreachable!("ContainerId {:?} points to a leaf", container_id)
1440            }
1441            Some(SplitNode::Grouped { .. }) => {
1442                unreachable!("ContainerId {:?} points to a Grouped node", container_id)
1443            }
1444            None => {
1445                unreachable!("ContainerId {:?} not found in split tree", container_id)
1446            }
1447        }
1448    }
1449
1450    /// Find the parent container of a leaf
1451    pub fn parent_container_of(&self, leaf_id: LeafId) -> Option<ContainerId> {
1452        self.root.parent_container_of(leaf_id.into())
1453    }
1454
1455    /// Get all visible buffer views with their rectangles
1456    pub fn get_visible_buffers(&self, viewport_rect: Rect) -> Vec<(LeafId, BufferId, Rect)> {
1457        // If a split is maximized, only show that split taking up the full viewport
1458        if let Some(maximized_id) = self.maximized_split {
1459            if let Some(SplitNode::Leaf {
1460                buffer_id,
1461                split_id,
1462                ..
1463            }) = self.root.find(maximized_id)
1464            {
1465                return vec![(*split_id, *buffer_id, viewport_rect)];
1466            }
1467            // Maximized split no longer exists, clear it and fall through
1468        }
1469        self.root.get_leaves_with_rects(viewport_rect)
1470    }
1471
1472    /// Get all split separator positions for rendering borders
1473    /// Returns (direction, x, y, length) tuples
1474    pub fn get_separators(&self, viewport_rect: Rect) -> Vec<(SplitDirection, u16, u16, u16)> {
1475        // No separators when a split is maximized
1476        if self.maximized_split.is_some() {
1477            return vec![];
1478        }
1479        self.root.get_separators(viewport_rect)
1480    }
1481
1482    /// Get all split separator positions with their split IDs (for mouse hit testing)
1483    /// Returns (container_id, direction, x, y, length) tuples
1484    pub fn get_separators_with_ids(
1485        &self,
1486        viewport_rect: Rect,
1487    ) -> Vec<(ContainerId, SplitDirection, u16, u16, u16)> {
1488        // No separators when a split is maximized
1489        if self.maximized_split.is_some() {
1490            return vec![];
1491        }
1492        self.root.get_separators_with_ids(viewport_rect)
1493    }
1494
1495    /// Get the current ratio of a split container
1496    pub fn get_ratio(&self, split_id: SplitId) -> Option<f32> {
1497        if let Some(SplitNode::Split { ratio, .. }) = self.root.find(split_id) {
1498            Some(*ratio)
1499        } else {
1500            None
1501        }
1502    }
1503
1504    /// Set the exact ratio of a split container
1505    pub fn set_ratio(&mut self, container_id: ContainerId, new_ratio: f32) {
1506        match self.root.find_mut(container_id.into()) {
1507            Some(SplitNode::Split { ratio, .. }) => {
1508                *ratio = new_ratio.clamp(0.1, 0.9);
1509            }
1510            Some(SplitNode::Leaf { .. }) => {
1511                unreachable!("ContainerId {:?} points to a leaf", container_id)
1512            }
1513            Some(SplitNode::Grouped { .. }) => {
1514                unreachable!("ContainerId {:?} points to a Grouped node", container_id)
1515            }
1516            None => {
1517                unreachable!("ContainerId {:?} not found in split tree", container_id)
1518            }
1519        }
1520    }
1521
1522    /// Set a fixed size on a split container's first or second child.
1523    /// When set, the child gets exactly this many rows/cols instead of using the ratio.
1524    pub fn set_fixed_size(
1525        &mut self,
1526        container_id: ContainerId,
1527        first: Option<u16>,
1528        second: Option<u16>,
1529    ) {
1530        if let Some(SplitNode::Split {
1531            fixed_first,
1532            fixed_second,
1533            ..
1534        }) = self.root.find_mut(container_id.into())
1535        {
1536            *fixed_first = first;
1537            *fixed_second = second;
1538        }
1539    }
1540
1541    /// Distribute all visible splits evenly
1542    /// This sets the ratios of all container splits so that leaf splits get equal space
1543    pub fn distribute_splits_evenly(&mut self) {
1544        Self::distribute_node_evenly(&mut self.root);
1545    }
1546
1547    /// Recursively distribute a node's splits evenly
1548    /// Returns the number of leaves in this subtree
1549    fn distribute_node_evenly(node: &mut SplitNode) -> usize {
1550        match node {
1551            SplitNode::Leaf { .. } => 1,
1552            SplitNode::Grouped { layout, .. } => Self::distribute_node_evenly(layout),
1553            SplitNode::Split {
1554                first,
1555                second,
1556                ratio,
1557                ..
1558            } => {
1559                let first_leaves = Self::distribute_node_evenly(first);
1560                let second_leaves = Self::distribute_node_evenly(second);
1561                let total_leaves = first_leaves + second_leaves;
1562
1563                // Set ratio so each leaf gets equal space
1564                // ratio = proportion for first pane
1565                *ratio = (first_leaves as f32 / total_leaves as f32).clamp(0.1, 0.9);
1566
1567                total_leaves
1568            }
1569        }
1570    }
1571
1572    /// Navigate to the next split (circular)
1573    pub fn next_split(&mut self) {
1574        // Switching away from a maximized split would route focus to a
1575        // hidden leaf — only the maximized split is rendered — making
1576        // the cursor disappear (issue #1961). Restore the full layout
1577        // first. Mirrors the auto-unmaximize in `close_split`.
1578        self.maximized_split = None;
1579        let leaf_ids = self.root.leaf_split_ids();
1580        if let Some(pos) = leaf_ids.iter().position(|id| *id == self.active_split) {
1581            let next_pos = (pos + 1) % leaf_ids.len();
1582            self.active_split = leaf_ids[next_pos];
1583        }
1584    }
1585
1586    /// Navigate to the previous split (circular)
1587    pub fn prev_split(&mut self) {
1588        // See `next_split` for why we clear the maximized state.
1589        self.maximized_split = None;
1590        let leaf_ids = self.root.leaf_split_ids();
1591        if let Some(pos) = leaf_ids.iter().position(|id| *id == self.active_split) {
1592            let prev_pos = if pos == 0 { leaf_ids.len() } else { pos } - 1;
1593            self.active_split = leaf_ids[prev_pos];
1594        }
1595    }
1596
1597    /// Get all split IDs that display a specific buffer
1598    pub fn splits_for_buffer(&self, target_buffer_id: BufferId) -> Vec<LeafId> {
1599        self.root
1600            .get_leaves_with_rects(Rect {
1601                x: 0,
1602                y: 0,
1603                width: 1,
1604                height: 1,
1605            })
1606            .into_iter()
1607            .filter(|(_, buffer_id, _)| *buffer_id == target_buffer_id)
1608            .map(|(split_id, _, _)| split_id)
1609            .collect()
1610    }
1611
1612    /// Get the buffer ID for a specific leaf split
1613    pub fn buffer_for_split(&self, target_split_id: LeafId) -> Option<BufferId> {
1614        self.root
1615            .get_leaves_with_rects(Rect {
1616                x: 0,
1617                y: 0,
1618                width: 1,
1619                height: 1,
1620            })
1621            .into_iter()
1622            .find(|(split_id, _, _)| *split_id == target_split_id)
1623            .map(|(_, buffer_id, _)| buffer_id)
1624    }
1625
1626    /// Maximize the active split (hide all other splits temporarily)
1627    /// Returns Ok(()) if successful, Err if there's only one split
1628    pub fn maximize_split(&mut self) -> Result<(), String> {
1629        // Can't maximize if there's only one split
1630        if self.root.count_leaves() <= 1 {
1631            return Err("Cannot maximize: only one split exists".to_string());
1632        }
1633
1634        // Can't maximize if already maximized
1635        if self.maximized_split.is_some() {
1636            return Err("A split is already maximized".to_string());
1637        }
1638
1639        // Maximize the active split
1640        self.maximized_split = Some(self.active_split.into());
1641        Ok(())
1642    }
1643
1644    /// Unmaximize the currently maximized split (restore all splits)
1645    /// Returns Ok(()) if successful, Err if no split is maximized
1646    pub fn unmaximize_split(&mut self) -> Result<(), String> {
1647        if self.maximized_split.is_none() {
1648            return Err("No split is maximized".to_string());
1649        }
1650
1651        self.maximized_split = None;
1652        Ok(())
1653    }
1654
1655    /// Check if a split is currently maximized
1656    pub fn is_maximized(&self) -> bool {
1657        self.maximized_split.is_some()
1658    }
1659
1660    /// Get the currently maximized split ID (if any)
1661    pub fn maximized_split(&self) -> Option<SplitId> {
1662        self.maximized_split
1663    }
1664
1665    /// Toggle maximize state for the active split
1666    /// If maximized, unmaximize. If not maximized, maximize.
1667    /// Returns true if maximized, false if ununmaximized.
1668    pub fn toggle_maximize(&mut self) -> Result<bool, String> {
1669        if self.is_maximized() {
1670            self.unmaximize_split()?;
1671            Ok(false)
1672        } else {
1673            self.maximize_split()?;
1674            Ok(true)
1675        }
1676    }
1677
1678    /// Toggle maximize state for a specific leaf split.
1679    ///
1680    /// Used by the mouse handler so that clicking a split's maximize
1681    /// button targets that split rather than whichever split happens
1682    /// to be active. When already maximized, this unmaximizes regardless
1683    /// of which leaf was passed (only the maximized split's chrome is
1684    /// visible while maximized, so the click can only land on it).
1685    pub fn toggle_maximize_for(&mut self, target: LeafId) -> Result<bool, String> {
1686        if self.is_maximized() {
1687            self.unmaximize_split()?;
1688            Ok(false)
1689        } else {
1690            if self.root.count_leaves() <= 1 {
1691                return Err("Cannot maximize: only one split exists".to_string());
1692            }
1693            if self.root.find(target.into()).is_none() {
1694                return Err("Cannot maximize: split not found".to_string());
1695            }
1696            self.maximized_split = Some(target.into());
1697            Ok(true)
1698        }
1699    }
1700
1701    /// Get all leaf split IDs that belong to a specific sync group
1702    pub fn get_splits_in_group(
1703        &self,
1704        group_id: u32,
1705        view_states: &std::collections::HashMap<LeafId, SplitViewState>,
1706    ) -> Vec<LeafId> {
1707        self.root
1708            .leaf_split_ids()
1709            .into_iter()
1710            .filter(|id| {
1711                view_states
1712                    .get(id)
1713                    .and_then(|vs| vs.sync_group)
1714                    .is_some_and(|g| g == group_id)
1715            })
1716            .collect()
1717    }
1718
1719    // === Split labels ===
1720
1721    /// Set a label on a leaf split (e.g., "sidebar")
1722    pub fn set_label(&mut self, split_id: LeafId, label: String) {
1723        self.labels.insert(split_id.into(), label);
1724    }
1725
1726    /// Remove a label from a split
1727    pub fn clear_label(&mut self, split_id: SplitId) {
1728        self.labels.remove(&split_id);
1729    }
1730
1731    /// Get the label for a split (if any)
1732    pub fn get_label(&self, split_id: SplitId) -> Option<&str> {
1733        self.labels.get(&split_id).map(|s| s.as_str())
1734    }
1735
1736    /// Get all split labels (for workspace serialization)
1737    pub fn labels(&self) -> &HashMap<SplitId, String> {
1738        &self.labels
1739    }
1740
1741    /// Set the role tag on a leaf. No-op if `split_id` is not a leaf.
1742    /// Caller is responsible for the "at most one leaf per role" invariant
1743    /// — call `clear_role` on the previous holder first.
1744    pub fn set_leaf_role(&mut self, split_id: LeafId, new_role: Option<SplitRole>) {
1745        if let Some(node) = self.root.find_mut(split_id.into()) {
1746            node.set_role(new_role);
1747        }
1748    }
1749
1750    /// Find the unique leaf carrying the given role, if any.
1751    pub fn find_leaf_by_role(&self, target: SplitRole) -> Option<LeafId> {
1752        fn walk(node: &SplitNode, target: SplitRole) -> Option<LeafId> {
1753            match node {
1754                SplitNode::Leaf {
1755                    role: Some(r),
1756                    split_id,
1757                    ..
1758                } if *r == target => Some(*split_id),
1759                SplitNode::Leaf { .. } => None,
1760                SplitNode::Split { first, second, .. } => {
1761                    walk(first, target).or_else(|| walk(second, target))
1762                }
1763                SplitNode::Grouped { layout, .. } => walk(layout, target),
1764            }
1765        }
1766        walk(&self.root, target)
1767    }
1768
1769    /// Clear any leaf currently carrying the given role. Returns the leaf
1770    /// id whose role was cleared, if one was found. Used to enforce the
1771    /// "at most one leaf per role" invariant when transferring a role.
1772    pub fn clear_role(&mut self, target: SplitRole) -> Option<LeafId> {
1773        let leaf = self.find_leaf_by_role(target)?;
1774        self.set_leaf_role(leaf, None);
1775        Some(leaf)
1776    }
1777
1778    /// Find the first leaf split with the given label
1779    pub fn find_split_by_label(&self, label: &str) -> Option<LeafId> {
1780        self.root
1781            .leaf_split_ids()
1782            .into_iter()
1783            .find(|id| self.labels.get(&(*id).into()).is_some_and(|l| l == label))
1784    }
1785
1786    /// Find the first leaf split without a label
1787    pub fn find_unlabeled_leaf(&self) -> Option<LeafId> {
1788        self.root
1789            .leaf_split_ids()
1790            .into_iter()
1791            .find(|id| !self.labels.contains_key(&(*id).into()))
1792    }
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797    use super::*;
1798
1799    #[test]
1800    fn test_create_split_manager() {
1801        let buffer_id = BufferId(0);
1802        let manager = SplitManager::new(buffer_id);
1803
1804        assert_eq!(manager.active_buffer_id(), Some(buffer_id));
1805        assert_eq!(manager.root().count_leaves(), 1);
1806    }
1807
1808    #[test]
1809    fn test_horizontal_split() {
1810        let buffer_a = BufferId(0);
1811        let buffer_b = BufferId(1);
1812
1813        let mut manager = SplitManager::new(buffer_a);
1814        let result = manager.split_active(SplitDirection::Horizontal, buffer_b, 0.5);
1815
1816        assert!(result.is_ok());
1817        assert_eq!(manager.root().count_leaves(), 2);
1818    }
1819
1820    #[test]
1821    fn test_vertical_split() {
1822        let buffer_a = BufferId(0);
1823        let buffer_b = BufferId(1);
1824
1825        let mut manager = SplitManager::new(buffer_a);
1826        let result = manager.split_active(SplitDirection::Vertical, buffer_b, 0.5);
1827
1828        assert!(result.is_ok());
1829        assert_eq!(manager.root().count_leaves(), 2);
1830    }
1831
1832    #[test]
1833    fn test_nested_splits() {
1834        let buffer_a = BufferId(0);
1835        let buffer_b = BufferId(1);
1836        let buffer_c = BufferId(2);
1837
1838        let mut manager = SplitManager::new(buffer_a);
1839
1840        // Split horizontally
1841        manager
1842            .split_active(SplitDirection::Horizontal, buffer_b, 0.5)
1843            .unwrap();
1844
1845        // Split the second pane vertically
1846        manager
1847            .split_active(SplitDirection::Vertical, buffer_c, 0.5)
1848            .unwrap();
1849
1850        assert_eq!(manager.root().count_leaves(), 3);
1851    }
1852
1853    #[test]
1854    fn test_close_split() {
1855        let buffer_a = BufferId(0);
1856        let buffer_b = BufferId(1);
1857
1858        let mut manager = SplitManager::new(buffer_a);
1859        let new_split = manager
1860            .split_active(SplitDirection::Horizontal, buffer_b, 0.5)
1861            .unwrap();
1862
1863        assert_eq!(manager.root().count_leaves(), 2);
1864
1865        // Close the new split
1866        let result = manager.close_split(new_split);
1867        assert!(result.is_ok());
1868        assert_eq!(manager.root().count_leaves(), 1);
1869    }
1870
1871    #[test]
1872    fn test_cannot_close_last_split() {
1873        let buffer_a = BufferId(0);
1874        let mut manager = SplitManager::new(buffer_a);
1875
1876        let result = manager.close_split(manager.active_split());
1877        assert!(result.is_err());
1878    }
1879
1880    #[test]
1881    fn test_split_rect_horizontal() {
1882        let rect = Rect {
1883            x: 0,
1884            y: 0,
1885            width: 100,
1886            height: 100,
1887        };
1888
1889        let (first, second) = split_rect(rect, SplitDirection::Horizontal, 0.5);
1890
1891        // With 1 line reserved for separator: (100-1)/2 = 49.5 rounds to 50 and 49
1892        assert_eq!(first.height, 50);
1893        assert_eq!(second.height, 49);
1894        assert_eq!(first.width, 100);
1895        assert_eq!(second.width, 100);
1896        assert_eq!(first.y, 0);
1897        assert_eq!(second.y, 51); // first.y + first.height + 1 (separator)
1898    }
1899
1900    #[test]
1901    fn test_split_rect_vertical() {
1902        let rect = Rect {
1903            x: 0,
1904            y: 0,
1905            width: 100,
1906            height: 100,
1907        };
1908
1909        let (first, second) = split_rect(rect, SplitDirection::Vertical, 0.5);
1910
1911        // With 1 column reserved for separator: (100-1)/2 = 49.5 rounds to 50 and 49
1912        assert_eq!(first.width, 50);
1913        assert_eq!(second.width, 49);
1914        assert_eq!(first.height, 100);
1915        assert_eq!(second.height, 100);
1916        assert_eq!(first.x, 0);
1917        assert_eq!(second.x, 51); // first.x + first.width + 1 (separator)
1918    }
1919
1920    // === Split label tests ===
1921
1922    #[test]
1923    fn test_set_and_get_label() {
1924        let mut manager = SplitManager::new(BufferId(0));
1925        let split = manager.active_split();
1926
1927        assert_eq!(manager.get_label(split.into()), None);
1928
1929        manager.set_label(split, "sidebar".to_string());
1930        assert_eq!(manager.get_label(split.into()), Some("sidebar"));
1931    }
1932
1933    #[test]
1934    fn test_clear_label() {
1935        let mut manager = SplitManager::new(BufferId(0));
1936        let split = manager.active_split();
1937
1938        manager.set_label(split, "sidebar".to_string());
1939        assert!(manager.get_label(split.into()).is_some());
1940
1941        manager.clear_label(split.into());
1942        assert_eq!(manager.get_label(split.into()), None);
1943    }
1944
1945    #[test]
1946    fn test_find_split_by_label() {
1947        let mut manager = SplitManager::new(BufferId(0));
1948        let first_split = manager.active_split();
1949
1950        let second_split = manager
1951            .split_active(SplitDirection::Vertical, BufferId(1), 0.5)
1952            .unwrap();
1953
1954        manager.set_label(first_split, "sidebar".to_string());
1955
1956        assert_eq!(manager.find_split_by_label("sidebar"), Some(first_split));
1957        assert_eq!(manager.find_split_by_label("terminal"), None);
1958
1959        // The second split has no label
1960        assert_ne!(manager.find_split_by_label("sidebar"), Some(second_split));
1961    }
1962
1963    #[test]
1964    fn test_find_unlabeled_leaf() {
1965        let mut manager = SplitManager::new(BufferId(0));
1966        let first_split = manager.active_split();
1967
1968        let second_split = manager
1969            .split_active(SplitDirection::Vertical, BufferId(1), 0.5)
1970            .unwrap();
1971
1972        // No labels — first leaf returned
1973        assert!(manager.find_unlabeled_leaf().is_some());
1974
1975        // Label the first split — unlabeled should return the second
1976        manager.set_label(first_split, "sidebar".to_string());
1977        assert_eq!(manager.find_unlabeled_leaf(), Some(second_split));
1978
1979        // Label both — no unlabeled leaf
1980        manager.set_label(second_split, "terminal".to_string());
1981        assert_eq!(manager.find_unlabeled_leaf(), None);
1982    }
1983
1984    #[test]
1985    fn test_close_split_cleans_up_label() {
1986        let mut manager = SplitManager::new(BufferId(0));
1987        let _first_split = manager.active_split();
1988
1989        let second_split = manager
1990            .split_active(SplitDirection::Vertical, BufferId(1), 0.5)
1991            .unwrap();
1992
1993        manager.set_label(second_split, "sidebar".to_string());
1994        assert_eq!(manager.find_split_by_label("sidebar"), Some(second_split));
1995
1996        manager.close_split(second_split).unwrap();
1997
1998        // Label should be cleaned up
1999        assert_eq!(manager.find_split_by_label("sidebar"), None);
2000        assert_eq!(manager.get_label(second_split.into()), None);
2001    }
2002
2003    #[test]
2004    fn test_label_overwrite() {
2005        let mut manager = SplitManager::new(BufferId(0));
2006        let split = manager.active_split();
2007
2008        manager.set_label(split, "sidebar".to_string());
2009        assert_eq!(manager.get_label(split.into()), Some("sidebar"));
2010
2011        manager.set_label(split, "terminal".to_string());
2012        assert_eq!(manager.get_label(split.into()), Some("terminal"));
2013        assert_eq!(manager.find_split_by_label("sidebar"), None);
2014        assert_eq!(manager.find_split_by_label("terminal"), Some(split));
2015    }
2016
2017    #[test]
2018    fn test_find_unlabeled_leaf_single_split_no_label() {
2019        let manager = SplitManager::new(BufferId(0));
2020        // Single unlabeled split — should return it
2021        assert_eq!(manager.find_unlabeled_leaf(), Some(manager.active_split()));
2022    }
2023
2024    #[test]
2025    fn test_find_unlabeled_leaf_single_split_labeled() {
2026        let mut manager = SplitManager::new(BufferId(0));
2027        let split = manager.active_split();
2028        manager.set_label(split, "only".to_string());
2029        // Only split is labeled — returns None
2030        assert_eq!(manager.find_unlabeled_leaf(), None);
2031    }
2032
2033    /// Regression test: opening the Utility Dock when a vertical split
2034    /// already exists must put the dock as a sibling of the *root*, so
2035    /// it spans the full width below both side-by-side panes — not
2036    /// nested under whichever pane was active.
2037    #[test]
2038    fn test_split_root_positioned_with_existing_vertical_split() {
2039        // Set up: root is a vertical split with two leaves (left/right).
2040        let left = BufferId(0);
2041        let right = BufferId(1);
2042        let dock = BufferId(2);
2043        let mut manager = SplitManager::new(left);
2044        manager
2045            .split_active(SplitDirection::Vertical, right, 0.5)
2046            .expect("vertical split");
2047        // Sanity: root is a vertical Split with two leaves, count = 2.
2048        assert!(matches!(
2049            manager.root(),
2050            SplitNode::Split {
2051                direction: SplitDirection::Vertical,
2052                ..
2053            }
2054        ));
2055        assert_eq!(manager.root().count_leaves(), 2);
2056        // Active leaf is the right pane (vertical split sets the new
2057        // leaf active). Buggy behavior would split that leaf and nest
2058        // the dock under it.
2059        let active_before = manager.active_split();
2060
2061        // Act: split the *root* horizontally to add the dock.
2062        let dock_leaf = manager
2063            .split_root_positioned(SplitDirection::Horizontal, dock, 0.7, false)
2064            .expect("split_root_positioned");
2065
2066        // Assert: root is now a Horizontal Split whose first child is
2067        // the original Vertical split and whose second child is the
2068        // new dock leaf. The original two leaves remain siblings of
2069        // each other (still under the inner Vertical split).
2070        match manager.root() {
2071            SplitNode::Split {
2072                direction: SplitDirection::Horizontal,
2073                first,
2074                second,
2075                ..
2076            } => {
2077                assert!(
2078                    matches!(
2079                        first.as_ref(),
2080                        SplitNode::Split {
2081                            direction: SplitDirection::Vertical,
2082                            ..
2083                        }
2084                    ),
2085                    "first child of new root must be the original Vertical split, got {:?}",
2086                    first
2087                );
2088                match second.as_ref() {
2089                    SplitNode::Leaf {
2090                        buffer_id,
2091                        split_id,
2092                        ..
2093                    } => {
2094                        assert_eq!(*buffer_id, dock, "second child must be the dock leaf");
2095                        assert_eq!(
2096                            *split_id, dock_leaf,
2097                            "split_root_positioned must return the new leaf id"
2098                        );
2099                    }
2100                    other => panic!("expected dock leaf as second child, got {:?}", other),
2101                }
2102            }
2103            other => {
2104                panic!(
2105                    "root must be a Horizontal Split after split_root_positioned, got {:?}",
2106                    other
2107                );
2108            }
2109        }
2110        // Total leaf count went from 2 → 3.
2111        assert_eq!(manager.root().count_leaves(), 3);
2112        // The dock leaf must not be the previously-active leaf — it
2113        // must be a freshly-created sibling of the root.
2114        assert_ne!(
2115            dock_leaf, active_before,
2116            "dock must be a new sibling of the root, not the previously-active leaf"
2117        );
2118    }
2119
2120    /// Regression test for issue #1961: navigating to the next split
2121    /// while a split is maximized must unmaximize first, otherwise the
2122    /// newly-active split is hidden behind the maximized split's
2123    /// full-viewport rendering and the cursor "disappears".
2124    #[test]
2125    fn test_next_split_unmaximizes_when_maximized() {
2126        let buffer_a = BufferId(0);
2127        let buffer_b = BufferId(1);
2128
2129        let mut manager = SplitManager::new(buffer_a);
2130        manager
2131            .split_active(SplitDirection::Vertical, buffer_b, 0.5)
2132            .expect("vertical split");
2133        let first_active = manager.active_split();
2134
2135        manager.maximize_split().expect("maximize");
2136        assert!(manager.is_maximized());
2137
2138        manager.next_split();
2139
2140        assert!(
2141            !manager.is_maximized(),
2142            "next_split must unmaximize so the newly-active split is visible"
2143        );
2144        assert_ne!(
2145            manager.active_split(),
2146            first_active,
2147            "next_split must actually move to a different split"
2148        );
2149    }
2150
2151    /// Companion regression test for issue #1961 covering `prev_split`,
2152    /// which shares the same hidden-split hazard as `next_split`.
2153    #[test]
2154    fn test_prev_split_unmaximizes_when_maximized() {
2155        let buffer_a = BufferId(0);
2156        let buffer_b = BufferId(1);
2157
2158        let mut manager = SplitManager::new(buffer_a);
2159        manager
2160            .split_active(SplitDirection::Vertical, buffer_b, 0.5)
2161            .expect("vertical split");
2162        let first_active = manager.active_split();
2163
2164        manager.maximize_split().expect("maximize");
2165        assert!(manager.is_maximized());
2166
2167        manager.prev_split();
2168
2169        assert!(
2170            !manager.is_maximized(),
2171            "prev_split must unmaximize so the newly-active split is visible"
2172        );
2173        assert_ne!(
2174            manager.active_split(),
2175            first_active,
2176            "prev_split must actually move to a different split"
2177        );
2178    }
2179
2180    #[test]
2181    fn test_apply_config_defaults_applies_scroll_offset() {
2182        let mut view_state = BufferViewState::new(80, 24);
2183        assert_eq!(
2184            view_state.viewport.scroll_offset, 3,
2185            "default scroll_offset should be 3"
2186        );
2187
2188        view_state.apply_config_defaults(
2189            true,   // line_numbers
2190            true,   // highlight_current_line
2191            false,  // line_wrap
2192            false,  // wrap_indent
2193            None,   // wrap_column
2194            vec![], // rulers
2195            7,      // scroll_offset
2196        );
2197        assert_eq!(
2198            view_state.viewport.scroll_offset, 7,
2199            "apply_config_defaults should set scroll_offset on the viewport"
2200        );
2201    }
2202}