Skip to main content

vix_editor/
pane_tree.rs

1//! A binary tree of editor panes, for arbitrary split layouts (including a 2x2
2//! grid). Each leaf shows a tab; each internal node splits its area in two by a
3//! direction and ratio. Pure geometry + tree surgery so it is unit-testable; the
4//! host ([`crate::editor`]) owns which leaf is focused and the editor widgets.
5//!
6//! Leaves are addressed by their **in-order index** (left-to-right / top-to-bottom
7//! as laid out). The tree is capped at [`MAX_LEAVES`] panes.
8
9#![warn(clippy::pedantic)]
10#![forbid(unsafe_code)]
11#![deny(missing_docs)]
12
13use ratatui_core::layout::Rect;
14
15use crate::editor::SplitDir;
16
17/// Maximum number of panes (leaves) in the split tree.
18pub const MAX_LEAVES: usize = 4;
19
20/// One node of the split tree.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum Pane {
23    /// A pane showing tab index `usize`.
24    Leaf(usize),
25    /// A split of two child panes by `dir`, the first taking `ratio` percent.
26    Split {
27        /// Side-by-side (`Vertical`) or stacked (`Horizontal`).
28        dir: SplitDir,
29        /// Percent of the area given to the first child (clamped 10..=90).
30        ratio: u16,
31        /// First child (left / top).
32        first: Box<Pane>,
33        /// Second child (right / bottom).
34        second: Box<Pane>,
35    },
36}
37
38/// One laid-out leaf: its in-order index, the tab it shows, and its rectangle.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub struct LeafBox {
41    /// In-order leaf index.
42    pub leaf: usize,
43    /// Tab shown in this pane.
44    pub tab: usize,
45    /// Pane rectangle (before any per-pane scrollbar split).
46    pub rect: Rect,
47}
48
49impl Pane {
50    /// Number of leaves in this subtree.
51    #[must_use]
52    pub fn leaf_count(&self) -> usize {
53        match self {
54            Pane::Leaf(_) => 1,
55            Pane::Split { first, second, .. } => first.leaf_count() + second.leaf_count(),
56        }
57    }
58
59    /// The tab shown by each leaf, in in-order traversal order.
60    #[must_use]
61    pub fn leaf_tabs(&self) -> Vec<usize> {
62        let mut out = Vec::new();
63        self.collect_tabs(&mut out);
64        out
65    }
66
67    fn collect_tabs(&self, out: &mut Vec<usize>) {
68        match self {
69            Pane::Leaf(t) => out.push(*t),
70            Pane::Split { first, second, .. } => {
71                first.collect_tabs(out);
72                second.collect_tabs(out);
73            }
74        }
75    }
76
77    /// Clamp every leaf's tab to `< tab_count` (used after tabs close).
78    pub fn clamp_tabs(&mut self, tab_count: usize) {
79        match self {
80            Pane::Leaf(t) => *t = (*t).min(tab_count.saturating_sub(1)),
81            Pane::Split { first, second, .. } => {
82                first.clamp_tabs(tab_count);
83                second.clamp_tabs(tab_count);
84            }
85        }
86    }
87
88    /// Set the tab shown by the leaf at in-order index `n`.
89    pub fn set_leaf_tab(&mut self, n: usize, tab: usize) {
90        let mut i = 0;
91        self.visit_leaf_mut(&mut i, n, &mut |t| *t = tab);
92    }
93
94    /// The tab shown by the leaf at in-order index `n`, if it exists.
95    #[must_use]
96    pub fn leaf_tab(&self, n: usize) -> Option<usize> {
97        self.leaf_tabs().get(n).copied()
98    }
99
100    fn visit_leaf_mut(&mut self, i: &mut usize, target: usize, f: &mut impl FnMut(&mut usize)) {
101        match self {
102            Pane::Leaf(t) => {
103                if *i == target {
104                    f(t);
105                }
106                *i += 1;
107            }
108            Pane::Split { first, second, .. } => {
109                first.visit_leaf_mut(i, target, f);
110                second.visit_leaf_mut(i, target, f);
111            }
112        }
113    }
114
115    /// Split the leaf at in-order index `n` into two, the new `second` showing
116    /// `new_tab`. No-op when the tree is already at [`MAX_LEAVES`].
117    pub fn split_leaf(&mut self, n: usize, dir: SplitDir, new_tab: usize) {
118        if self.leaf_count() >= MAX_LEAVES {
119            return;
120        }
121        let mut i = 0;
122        self.split_leaf_inner(&mut i, n, dir, new_tab);
123    }
124
125    fn split_leaf_inner(&mut self, i: &mut usize, target: usize, dir: SplitDir, new_tab: usize) {
126        match self {
127            Pane::Leaf(t) => {
128                if *i == target {
129                    let old = *t;
130                    *self = Pane::Split {
131                        dir,
132                        ratio: 50,
133                        first: Box::new(Pane::Leaf(old)),
134                        second: Box::new(Pane::Leaf(new_tab)),
135                    };
136                }
137                *i += 1;
138            }
139            Pane::Split { first, second, .. } => {
140                first.split_leaf_inner(i, target, dir, new_tab);
141                second.split_leaf_inner(i, target, dir, new_tab);
142            }
143        }
144    }
145
146    /// Remove the leaf at in-order index `n`, collapsing its parent so the sibling
147    /// takes the parent's place. Returns the resulting tree, or `None` if the
148    /// whole tree was that single leaf.
149    #[must_use]
150    pub fn remove_leaf(self, n: usize) -> Option<Pane> {
151        let mut i = 0;
152        self.remove_leaf_inner(&mut i, n)
153    }
154
155    fn remove_leaf_inner(self, i: &mut usize, target: usize) -> Option<Pane> {
156        match self {
157            Pane::Leaf(_) => {
158                let here = *i;
159                *i += 1;
160                if here == target { None } else { Some(self) }
161            }
162            Pane::Split {
163                dir,
164                ratio,
165                first,
166                second,
167            } => {
168                let first = first.remove_leaf_inner(i, target);
169                let second = second.remove_leaf_inner(i, target);
170                match (first, second) {
171                    (Some(a), Some(b)) => Some(Pane::Split {
172                        dir,
173                        ratio,
174                        first: Box::new(a),
175                        second: Box::new(b),
176                    }),
177                    (Some(only), None) | (None, Some(only)) => Some(only),
178                    (None, None) => None,
179                }
180            }
181        }
182    }
183
184    /// Lay out every leaf into `area`, returning their rectangles in in-order.
185    #[must_use]
186    pub fn layout(&self, area: Rect) -> Vec<LeafBox> {
187        let mut out = Vec::new();
188        let mut i = 0;
189        self.layout_inner(area, &mut i, &mut out);
190        out
191    }
192
193    fn layout_inner(&self, area: Rect, i: &mut usize, out: &mut Vec<LeafBox>) {
194        match self {
195            Pane::Leaf(tab) => {
196                out.push(LeafBox {
197                    leaf: *i,
198                    tab: *tab,
199                    rect: area,
200                });
201                *i += 1;
202            }
203            Pane::Split {
204                dir,
205                ratio,
206                first,
207                second,
208            } => {
209                let (a, _div, b) = split_rects(area, *dir, *ratio);
210                first.layout_inner(a, i, out);
211                second.layout_inner(b, i, out);
212            }
213        }
214    }
215
216    /// The divider rectangles (one per internal node), with their direction, for
217    /// drawing the split lines.
218    #[must_use]
219    pub fn dividers(&self, area: Rect) -> Vec<(SplitDir, Rect)> {
220        let mut out = Vec::new();
221        self.dividers_inner(area, &mut out);
222        out
223    }
224
225    fn dividers_inner(&self, area: Rect, out: &mut Vec<(SplitDir, Rect)>) {
226        if let Pane::Split {
227            dir,
228            ratio,
229            first,
230            second,
231        } = self
232        {
233            let (a, div, b) = split_rects(area, *dir, *ratio);
234            out.push((*dir, div));
235            first.dividers_inner(a, out);
236            second.dividers_inner(b, out);
237        }
238    }
239
240    /// The in-order leaf index whose rectangle contains `(col, row)`, if any.
241    #[must_use]
242    pub fn leaf_at(&self, area: Rect, col: u16, row: u16) -> Option<usize> {
243        self.layout(area)
244            .into_iter()
245            .find(|b| contains(b.rect, col, row))
246            .map(|b| b.leaf)
247    }
248
249    /// If `(col, row)` is on a divider, set that split's ratio from the pointer's
250    /// position within the split's area; returns whether anything changed.
251    pub fn resize_at(&mut self, area: Rect, col: u16, row: u16) -> bool {
252        if let Pane::Split {
253            dir,
254            ratio,
255            first,
256            second,
257        } = self
258        {
259            let (a, div, b) = split_rects(area, *dir, *ratio);
260            if contains(div, col, row) {
261                *ratio = ratio_from_pointer(area, *dir, col, row);
262                return true;
263            }
264            return first.resize_at(a, col, row) || second.resize_at(b, col, row);
265        }
266        false
267    }
268}
269
270/// Compute the (first, divider, second) rectangles of a split of `area`.
271fn split_rects(area: Rect, dir: SplitDir, ratio: u16) -> (Rect, Rect, Rect) {
272    let ratio = ratio.clamp(10, 90);
273    match dir {
274        SplitDir::Vertical => {
275            let total = area.width;
276            let w0 = u16::try_from(u32::from(total) * u32::from(ratio) / 100).unwrap_or(u16::MAX);
277            let w0 = w0.clamp(1, total.saturating_sub(2).max(1));
278            (
279                Rect { width: w0, ..area },
280                Rect {
281                    x: area.x + w0,
282                    y: area.y,
283                    width: 1,
284                    height: area.height,
285                },
286                Rect {
287                    x: area.x + w0 + 1,
288                    y: area.y,
289                    width: total.saturating_sub(w0 + 1),
290                    height: area.height,
291                },
292            )
293        }
294        SplitDir::Horizontal => {
295            let total = area.height;
296            let h0 = u16::try_from(u32::from(total) * u32::from(ratio) / 100).unwrap_or(u16::MAX);
297            let h0 = h0.clamp(1, total.saturating_sub(2).max(1));
298            (
299                Rect { height: h0, ..area },
300                Rect {
301                    x: area.x,
302                    y: area.y + h0,
303                    width: area.width,
304                    height: 1,
305                },
306                Rect {
307                    x: area.x,
308                    y: area.y + h0 + 1,
309                    width: area.width,
310                    height: total.saturating_sub(h0 + 1),
311                },
312            )
313        }
314    }
315}
316
317/// The ratio (10..=90) for a pointer at `(col, row)` within `area` along `dir`.
318fn ratio_from_pointer(area: Rect, dir: SplitDir, col: u16, row: u16) -> u16 {
319    let pct = match dir {
320        SplitDir::Vertical if area.width > 1 => {
321            u32::from(col.saturating_sub(area.x)) * 100 / u32::from(area.width)
322        }
323        SplitDir::Horizontal if area.height > 1 => {
324            u32::from(row.saturating_sub(area.y)) * 100 / u32::from(area.height)
325        }
326        _ => 50,
327    };
328    u16::try_from(pct).unwrap_or(50).clamp(10, 90)
329}
330
331/// Whether `rect` contains `(col, row)`.
332fn contains(rect: Rect, col: u16, row: u16) -> bool {
333    col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn area() -> Rect {
341        Rect {
342            x: 0,
343            y: 0,
344            width: 80,
345            height: 24,
346        }
347    }
348
349    #[test]
350    fn split_and_count_and_tabs() {
351        let mut p = Pane::Leaf(0);
352        p.split_leaf(0, SplitDir::Vertical, 1);
353        assert_eq!(p.leaf_count(), 2);
354        assert_eq!(p.leaf_tabs(), vec![0, 1]);
355        // Split the second leaf to make a 3-pane tree.
356        p.split_leaf(1, SplitDir::Horizontal, 2);
357        assert_eq!(p.leaf_tabs(), vec![0, 1, 2]);
358    }
359
360    #[test]
361    fn split_caps_at_max_leaves() {
362        let mut p = Pane::Leaf(0);
363        for t in 1..10 {
364            p.split_leaf(0, SplitDir::Vertical, t);
365        }
366        assert_eq!(p.leaf_count(), MAX_LEAVES);
367    }
368
369    #[test]
370    fn remove_collapses_parent() {
371        let mut p = Pane::Leaf(0);
372        p.split_leaf(0, SplitDir::Vertical, 1);
373        p.split_leaf(1, SplitDir::Horizontal, 2); // leaves: 0,1,2
374        let p = p.remove_leaf(1).unwrap();
375        assert_eq!(p.leaf_tabs(), vec![0, 2]);
376        let single = p.remove_leaf(0).unwrap();
377        assert_eq!(single.leaf_tabs(), vec![2]);
378        assert!(single.remove_leaf(0).is_none());
379    }
380
381    #[test]
382    fn layout_partitions_the_area() {
383        let mut p = Pane::Leaf(0);
384        p.split_leaf(0, SplitDir::Vertical, 1);
385        let boxes = p.layout(area());
386        assert_eq!(boxes.len(), 2);
387        assert_eq!(boxes[0].rect.x, 0);
388        assert!(boxes[1].rect.x > boxes[0].rect.width); // second pane is to the right
389        // The pointer in the left pane resolves to leaf 0.
390        assert_eq!(p.leaf_at(area(), 5, 5), Some(0));
391    }
392
393    #[test]
394    fn resize_changes_ratio_on_divider() {
395        let mut p = Pane::Leaf(0);
396        p.split_leaf(0, SplitDir::Vertical, 1);
397        let div = p.dividers(area())[0].1;
398        assert!(
399            p.resize_at(area(), div.x, div.y),
400            "pointer on divider resizes"
401        );
402        // Off a divider does nothing.
403        assert!(!p.resize_at(area(), 1, 1));
404    }
405}