Skip to main content

xei_core/
split.rs

1//! Editor pane splits (vertical / horizontal), up to [`MAX_PANES`] panes in a
2//! single direction. Repeating `Ctrl+W v` / `Ctrl+W s` adds another pane next
3//! to the focused one (Vim-style enough for daily use; no mixed-direction
4//! trees yet).
5
6/// Hard cap — panes get unusably narrow beyond this.
7pub const MAX_PANES: usize = 4;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum SplitKind {
11    #[default]
12    None,
13    /// Side by side (left | right)
14    Vertical,
15    /// Stacked (top / bottom)
16    Horizontal,
17}
18
19#[derive(Debug, Clone)]
20pub struct Pane {
21    pub tab_index: usize,
22    pub scroll: usize,
23    /// Per-pane cursor (row, col) — Vim-style independent window cursors.
24    pub cursor: (usize, usize),
25}
26
27impl Default for Pane {
28    fn default() -> Self {
29        Self {
30            tab_index: 0,
31            scroll: 0,
32            cursor: (0, 0),
33        }
34    }
35}
36
37/// Outcome of a split request (drives the status message).
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SplitAdd {
40    Opened,
41    Added,
42    Full,
43    /// Already split in the other direction — no mixed trees (yet).
44    MixedKind,
45}
46
47#[derive(Debug, Clone)]
48pub struct SplitState {
49    pub kind: SplitKind,
50    /// Divider position for the 2-pane case (drag-resize); ≥3 panes are equal.
51    pub ratio: f32,
52    /// Focused pane index.
53    pub focus: usize,
54    pub panes: Vec<Pane>,
55    /// After `Ctrl+W` waiting for chord
56    pub pending_chord: bool,
57}
58
59impl Default for SplitState {
60    fn default() -> Self {
61        Self {
62            kind: SplitKind::None,
63            ratio: 0.5,
64            focus: 0,
65            panes: vec![Pane::default(), Pane::default()],
66            pending_chord: false,
67        }
68    }
69}
70
71impl SplitState {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    pub fn is_split(&self) -> bool {
77        self.kind != SplitKind::None && self.panes.len() >= 2
78    }
79
80    pub fn pane_count(&self) -> usize {
81        if self.is_split() {
82            self.panes.len()
83        } else {
84            1
85        }
86    }
87
88    fn clamp_focus(&self) -> usize {
89        self.focus.min(self.panes.len().saturating_sub(1))
90    }
91
92    pub fn focused_pane(&self) -> &Pane {
93        &self.panes[self.clamp_focus()]
94    }
95
96    pub fn focused_pane_mut(&mut self) -> &mut Pane {
97        let i = self.clamp_focus();
98        &mut self.panes[i]
99    }
100
101    /// Open a split of the given kind over the current tab/scroll, or add
102    /// another pane when already split in the same direction.
103    pub fn open_split(
104        &mut self,
105        kind: SplitKind,
106        tab: usize,
107        scroll: usize,
108        cursor: (usize, usize),
109    ) -> SplitAdd {
110        if kind == SplitKind::None {
111            self.close();
112            return SplitAdd::Opened;
113        }
114        self.pending_chord = false;
115        if self.is_split() {
116            if self.kind != kind {
117                return SplitAdd::MixedKind;
118            }
119            if self.panes.len() >= MAX_PANES {
120                return SplitAdd::Full;
121            }
122            // New pane opens next to the focused one and takes focus.
123            let at = self.clamp_focus() + 1;
124            self.panes.insert(
125                at,
126                Pane {
127                    tab_index: tab,
128                    scroll,
129                    cursor,
130                },
131            );
132            self.focus = at;
133            return SplitAdd::Added;
134        }
135        self.kind = kind;
136        self.ratio = 0.5;
137        self.focus = 0;
138        self.panes = vec![
139            Pane {
140                tab_index: tab,
141                scroll,
142                cursor,
143            },
144            // Second pane starts on same tab (VS Code-ish); user can switch later.
145            Pane {
146                tab_index: tab,
147                scroll,
148                cursor,
149            },
150        ];
151        SplitAdd::Opened
152    }
153
154    /// Remove the focused pane; focus lands on the neighbor. Returns the
155    /// surviving pane snapshot to adopt when the split collapses to one.
156    pub fn remove_focused(&mut self) -> Option<Pane> {
157        if !self.is_split() {
158            return None;
159        }
160        let idx = self.clamp_focus();
161        self.panes.remove(idx);
162        self.focus = idx.min(self.panes.len().saturating_sub(1));
163        if self.panes.len() < 2 {
164            let survivor = self.panes.first().cloned();
165            self.close_keep_panes();
166            return survivor;
167        }
168        Some(self.focused_pane().clone())
169    }
170
171    pub fn close(&mut self) {
172        self.kind = SplitKind::None;
173        self.focus = 0;
174        self.pending_chord = false;
175        self.panes = vec![Pane::default(), Pane::default()];
176    }
177
178    fn close_keep_panes(&mut self) {
179        self.kind = SplitKind::None;
180        self.focus = 0;
181        self.pending_chord = false;
182        if self.panes.is_empty() {
183            self.panes = vec![Pane::default()];
184        }
185        while self.panes.len() < 2 {
186            let last = self.panes.last().cloned().unwrap_or_default();
187            self.panes.push(last);
188        }
189    }
190
191    pub fn focus_other(&mut self) {
192        if self.is_split() {
193            self.focus = (self.clamp_focus() + 1) % self.panes.len();
194        }
195    }
196
197    pub fn set_focus(&mut self, idx: usize) {
198        if self.is_split() {
199            self.focus = idx.min(self.panes.len() - 1);
200        }
201    }
202
203    pub fn adjust_ratio(&mut self, delta: f32) {
204        self.ratio = (self.ratio + delta).clamp(0.2, 0.8);
205    }
206
207    pub fn equalize(&mut self) {
208        self.ratio = 0.5;
209    }
210
211    /// Keep pane tab indices valid after tab close/reorder.
212    pub fn clamp_tabs(&mut self, n_tabs: usize) {
213        if n_tabs == 0 {
214            return;
215        }
216        for p in &mut self.panes {
217            if p.tab_index >= n_tabs {
218                p.tab_index = n_tabs - 1;
219            }
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn repeated_splits_add_panes_up_to_cap() {
230        let mut s = SplitState::new();
231        assert_eq!(s.open_split(SplitKind::Vertical, 0, 0, (0, 0)), SplitAdd::Opened);
232        assert_eq!(s.pane_count(), 2);
233        assert_eq!(s.open_split(SplitKind::Vertical, 1, 3, (3, 0)), SplitAdd::Added);
234        assert_eq!(s.pane_count(), 3);
235        // New pane sits next to previous focus and takes focus.
236        assert_eq!(s.focus, 1);
237        assert_eq!(s.focused_pane().tab_index, 1);
238        assert_eq!(s.open_split(SplitKind::Vertical, 0, 0, (0, 0)), SplitAdd::Added);
239        assert_eq!(s.pane_count(), 4);
240        assert_eq!(s.open_split(SplitKind::Vertical, 0, 0, (0, 0)), SplitAdd::Full);
241        assert_eq!(s.open_split(SplitKind::Horizontal, 0, 0, (0, 0)), SplitAdd::MixedKind);
242    }
243
244    #[test]
245    fn remove_focused_collapses_to_single() {
246        let mut s = SplitState::new();
247        s.open_split(SplitKind::Vertical, 0, 0, (0, 0));
248        s.open_split(SplitKind::Vertical, 2, 9, (9, 0)); // 3 panes, focus=1 (tab 2)
249        s.set_focus(1);
250        let survivor = s.remove_focused().expect("still split");
251        // Focus falls to the neighbor at the same index.
252        assert!(s.is_split());
253        assert_eq!(s.pane_count(), 2);
254        assert_eq!(survivor.tab_index, s.focused_pane().tab_index);
255        // Removing again collapses the split and yields the last survivor.
256        let last = s.remove_focused().expect("survivor");
257        assert!(!s.is_split());
258        assert_eq!(last.tab_index, 0);
259    }
260
261    #[test]
262    fn focus_cycles_all_panes() {
263        let mut s = SplitState::new();
264        s.open_split(SplitKind::Horizontal, 0, 0, (0, 0));
265        s.open_split(SplitKind::Horizontal, 0, 0, (0, 0));
266        assert_eq!(s.pane_count(), 3);
267        s.set_focus(0);
268        s.focus_other();
269        s.focus_other();
270        assert_eq!(s.focus, 2);
271        s.focus_other();
272        assert_eq!(s.focus, 0);
273    }
274}