Skip to main content

termden_core/
layout.rs

1use serde::{Deserialize, Serialize};
2
3use crate::model::HarnessId;
4
5/// Split direction. `Horizontal` places children side by side (a vertical
6/// divider); `Vertical` stacks them.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum Axis {
10    Horizontal,
11    Vertical,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DropZone {
16    Left,
17    Right,
18    Top,
19    Bottom,
20    Center,
21}
22
23impl DropZone {
24    pub fn is_center(self) -> bool {
25        matches!(self, DropZone::Center)
26    }
27}
28
29/// Binary dwindle tree. Leaves reference harnesses that live in the same
30/// workspace; the tree only describes geometry.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum LayoutNode {
34    Leaf(HarnessId),
35    Split {
36        axis: Axis,
37        /// Share of the available space given to `first`, between 0.1 and 0.9.
38        ratio: f32,
39        first: Box<LayoutNode>,
40        second: Box<LayoutNode>,
41    },
42}
43
44impl LayoutNode {
45    pub fn leaf(id: HarnessId) -> Self {
46        Self::Leaf(id)
47    }
48
49    pub fn split(axis: Axis, ratio: f32, first: LayoutNode, second: LayoutNode) -> Self {
50        Self::Split {
51            axis,
52            ratio: ratio.clamp(0.1, 0.9),
53            first: Box::new(first),
54            second: Box::new(second),
55        }
56    }
57
58    pub fn leaves(&self) -> Vec<HarnessId> {
59        let mut out = Vec::new();
60        self.collect_leaves(&mut out);
61        out
62    }
63
64    fn collect_leaves(&self, out: &mut Vec<HarnessId>) {
65        match self {
66            LayoutNode::Leaf(id) => out.push(*id),
67            LayoutNode::Split { first, second, .. } => {
68                first.collect_leaves(out);
69                second.collect_leaves(out);
70            }
71        }
72    }
73
74    pub fn len(&self) -> usize {
75        match self {
76            LayoutNode::Leaf(_) => 1,
77            LayoutNode::Split { first, second, .. } => first.len() + second.len(),
78        }
79    }
80
81    pub fn is_empty(&self) -> bool {
82        false
83    }
84
85    pub fn contains(&self, id: HarnessId) -> bool {
86        match self {
87            LayoutNode::Leaf(leaf) => *leaf == id,
88            LayoutNode::Split { first, second, .. } => first.contains(id) || second.contains(id),
89        }
90    }
91
92    pub fn first_leaf(&self) -> Option<HarnessId> {
93        match self {
94            LayoutNode::Leaf(id) => Some(*id),
95            LayoutNode::Split { first, .. } => first.first_leaf(),
96        }
97    }
98
99    /// Insert `new` next to `target`, splitting in the requested direction.
100    pub fn split_at(&mut self, target: HarnessId, new: HarnessId, zone: DropZone) -> bool {
101        if zone.is_center() {
102            return false;
103        }
104
105        match self {
106            LayoutNode::Leaf(id) if *id == target => {
107                let old = self.clone();
108                *self = match zone {
109                    DropZone::Left => Self::split(Axis::Horizontal, 0.5, Self::leaf(new), old),
110                    DropZone::Right => Self::split(Axis::Horizontal, 0.5, old, Self::leaf(new)),
111                    DropZone::Top => Self::split(Axis::Vertical, 0.5, Self::leaf(new), old),
112                    DropZone::Bottom => Self::split(Axis::Vertical, 0.5, old, Self::leaf(new)),
113                    DropZone::Center => unreachable!(),
114                };
115                true
116            }
117            LayoutNode::Leaf(_) => false,
118            LayoutNode::Split { first, second, .. } => {
119                first.split_at(target, new, zone) || second.split_at(target, new, zone)
120            }
121        }
122    }
123
124    /// Swap two leaves in place. Used for the center drop zone.
125    pub fn swap(&mut self, a: HarnessId, b: HarnessId) -> bool {
126        if a == b {
127            return false;
128        }
129
130        let mut found_a = false;
131        let mut found_b = false;
132        self.for_each_leaf_mut(&mut |id| {
133            if *id == a {
134                *id = b;
135                found_a = true;
136            } else if *id == b {
137                *id = a;
138                found_b = true;
139            }
140        });
141        found_a && found_b
142    }
143
144    fn for_each_leaf_mut(&mut self, f: &mut impl FnMut(&mut HarnessId)) {
145        match self {
146            LayoutNode::Leaf(id) => f(id),
147            LayoutNode::Split { first, second, .. } => {
148                first.for_each_leaf_mut(f);
149                second.for_each_leaf_mut(f);
150            }
151        }
152    }
153
154    /// Remove a leaf, collapsing its parent split. Returns false for the root
155    /// leaf; callers should keep at least one leaf alive.
156    pub fn remove(&mut self, id: HarnessId) -> bool {
157        let replacement = match self {
158            LayoutNode::Leaf(_) => return false,
159            LayoutNode::Split { first, second, .. } => {
160                if matches!(first.as_ref(), LayoutNode::Leaf(leaf) if *leaf == id) {
161                    Some((**second).clone())
162                } else if matches!(second.as_ref(), LayoutNode::Leaf(leaf) if *leaf == id) {
163                    Some((**first).clone())
164                } else if first.remove(id) || second.remove(id) {
165                    return true;
166                } else {
167                    None
168                }
169            }
170        };
171
172        match replacement {
173            Some(node) => {
174                *self = node;
175                true
176            }
177            None => false,
178        }
179    }
180
181    /// Set the ratio of the split at `path` (`false` = first child,
182    /// `true` = second, from the root). Returns false if there is none.
183    pub fn set_ratio(&mut self, path: &[bool], value: f32) -> bool {
184        match (self, path) {
185            (LayoutNode::Split { ratio, .. }, []) => {
186                *ratio = value.clamp(0.1, 0.9);
187                true
188            }
189            (LayoutNode::Split { first, second, .. }, [side, rest @ ..]) => {
190                if *side { second } else { first }.set_ratio(rest, value)
191            }
192            (LayoutNode::Leaf(_), _) => false,
193        }
194    }
195
196    /// Remove `dragged` and re-insert it relative to `target`.
197    pub fn move_leaf(&mut self, dragged: HarnessId, target: HarnessId, zone: DropZone) -> bool {
198        if dragged == target {
199            return false;
200        }
201        if self.len() < 2 || !self.contains(target) {
202            return false;
203        }
204
205        if zone.is_center() {
206            return self.swap(dragged, target);
207        }
208
209        self.remove(dragged);
210        if !self.contains(target) {
211            return false;
212        }
213        self.split_at(target, dragged, zone)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn set_ratio_follows_path() {
223        let id = HarnessId::new;
224        let inner = LayoutNode::split(
225            Axis::Vertical,
226            0.5,
227            LayoutNode::leaf(id(2)),
228            LayoutNode::leaf(id(3)),
229        );
230        let mut root = LayoutNode::split(Axis::Horizontal, 0.5, LayoutNode::leaf(id(1)), inner);
231        assert!(root.set_ratio(&[true], 0.3));
232        assert!(root.set_ratio(&[], 2.0));
233        assert!(!root.set_ratio(&[false], 0.4));
234        let LayoutNode::Split { ratio, second, .. } = &root else {
235            panic!()
236        };
237        assert_eq!(*ratio, 0.9);
238        assert!(matches!(**second, LayoutNode::Split { ratio, .. } if ratio == 0.3));
239    }
240}