Skip to main content

khora_core/ui/editor/
dock.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Dock model — the tree of splits and tab groups behind the editor workbench.
16//!
17//! Pure data and geometry: no backend, no painting, no interaction. The shell
18//! asks for a [`DockLayout`] and paints it; the widget layer turns pointer
19//! events into calls back into [`DockTree`]. Keeping the model here means the
20//! rules that are easy to get wrong — where a drop lands, what happens to a
21//! split when its last tab closes, how ratios clamp — are testable without a
22//! window.
23//!
24//! Chrome (title bar, spine, status bar) deliberately lives *outside* the dock:
25//! those are the frame the workbench sits in, not panels the user rearranges.
26
27use serde::{Deserialize, Serialize};
28
29/// A rectangle as `[x, y, width, height]`, matching the convention used by
30/// `UiBuilder` throughout the UI layer.
31pub type DockRect = [f32; 4];
32
33/// Which way a split divides its area.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum SplitAxis {
36    /// Children sit side by side; the ratio splits the **width**.
37    Horizontal,
38    /// Children sit one above the other; the ratio splits the **height**.
39    Vertical,
40}
41
42/// Where a dragged panel would land relative to the group under the pointer.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DropZone {
45    /// Join the group under the pointer as another tab.
46    Center,
47    /// Split it, taking the left half.
48    Left,
49    /// Split it, taking the right half.
50    Right,
51    /// Split it, taking the top half.
52    Top,
53    /// Split it, taking the bottom half.
54    Bottom,
55}
56
57impl DropZone {
58    /// The axis a drop in this zone splits along, or `None` for [`Center`],
59    /// which joins an existing group instead of splitting.
60    ///
61    /// [`Center`]: DropZone::Center
62    pub const fn axis(self) -> Option<SplitAxis> {
63        match self {
64            DropZone::Center => None,
65            DropZone::Left | DropZone::Right => Some(SplitAxis::Horizontal),
66            DropZone::Top | DropZone::Bottom => Some(SplitAxis::Vertical),
67        }
68    }
69
70    /// Whether the dropped panel takes the **first** child of the new split.
71    pub const fn takes_first(self) -> bool {
72        matches!(self, DropZone::Left | DropZone::Top)
73    }
74}
75
76/// Stable handle for a split, so a resize drag can name the divider it grabbed
77/// without depending on the tree's shape surviving the gesture.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79pub struct SplitId(pub u32);
80
81/// A node of the dock tree.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub enum DockNode {
84    /// A group of panels shown as tabs. Never empty — an emptied group is
85    /// collapsed by [`DockTree::remove`] rather than left behind, because a
86    /// zero-tab group would occupy space that has nothing to show.
87    Tabs {
88        /// Panel ids, in tab order.
89        panels: Vec<String>,
90        /// Index into `panels` of the visible one.
91        active: usize,
92    },
93    /// Two children sharing the area along `axis`.
94    Split {
95        /// Stable handle for resize interaction.
96        id: SplitId,
97        /// Direction of the division.
98        axis: SplitAxis,
99        /// Fraction of the area given to `first`, clamped to a sane band.
100        ratio: f32,
101        /// Leading child — left or top.
102        first: Box<DockNode>,
103        /// Trailing child — right or bottom.
104        second: Box<DockNode>,
105    },
106}
107
108/// Thickness of a splitter's grab band, in points. Wide enough to hit without
109/// aiming, narrow enough not to steal clicks from the panels either side.
110pub const SPLITTER_THICKNESS: f32 = 6.0;
111
112/// Smallest area a tab group may be squeezed to. Ratios clamp so a drag can
113/// never collapse a panel to nothing — a panel you cannot see is a panel you
114/// cannot get back without knowing the layout can be reset.
115pub const MIN_PANE: f32 = 120.0;
116
117/// One tab group placed on screen.
118#[derive(Debug, Clone, PartialEq)]
119pub struct TabGroupLayout {
120    /// Area assigned to the whole group, tab strip included.
121    pub rect: DockRect,
122    /// Panel ids in tab order.
123    pub panels: Vec<String>,
124    /// Index of the visible panel.
125    pub active: usize,
126}
127
128impl TabGroupLayout {
129    /// Id of the panel whose content should be drawn.
130    pub fn active_panel(&self) -> Option<&str> {
131        self.panels.get(self.active).map(|s| s.as_str())
132    }
133}
134
135/// One draggable divider placed on screen.
136#[derive(Debug, Clone, PartialEq)]
137pub struct SplitterLayout {
138    /// Handle to pass back to [`DockTree::set_ratio`].
139    pub id: SplitId,
140    /// Grab band, already thickened to [`SPLITTER_THICKNESS`].
141    pub rect: DockRect,
142    /// Which way dragging it moves the boundary.
143    pub axis: SplitAxis,
144    /// Area the split governs — the caller needs it to turn a pointer position
145    /// back into a ratio.
146    pub bounds: DockRect,
147}
148
149/// Everything the shell needs to paint one frame of the dock.
150#[derive(Debug, Clone, Default, PartialEq)]
151pub struct DockLayout {
152    /// Tab groups, in traversal order.
153    pub groups: Vec<TabGroupLayout>,
154    /// Dividers between them.
155    pub splitters: Vec<SplitterLayout>,
156}
157
158/// A tree of splits and tab groups covering one workbench area.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct DockTree {
161    root: Option<DockNode>,
162    /// Monotonic source of [`SplitId`]s. Never reused, so a handle held across
163    /// a structural change can only go stale, never silently address a
164    /// different divider.
165    next_split: u32,
166}
167
168impl Default for DockTree {
169    fn default() -> Self {
170        Self::empty()
171    }
172}
173
174impl DockTree {
175    /// A dock with nothing in it.
176    pub fn empty() -> Self {
177        Self {
178            root: None,
179            next_split: 0,
180        }
181    }
182
183    /// A dock holding a single panel.
184    pub fn single(panel: impl Into<String>) -> Self {
185        Self {
186            root: Some(DockNode::Tabs {
187                panels: vec![panel.into()],
188                active: 0,
189            }),
190            next_split: 0,
191        }
192    }
193
194    /// Whether the dock holds no panels at all.
195    pub fn is_empty(&self) -> bool {
196        self.root.is_none()
197    }
198
199    /// Every panel id in the tree, in traversal order.
200    pub fn panels(&self) -> Vec<String> {
201        let mut out = Vec::new();
202        if let Some(root) = &self.root {
203            collect_panels(root, &mut out);
204        }
205        out
206    }
207
208    /// Whether `panel` is somewhere in the tree.
209    pub fn contains(&self, panel: &str) -> bool {
210        self.panels().iter().any(|p| p == panel)
211    }
212
213    fn alloc_split(&mut self) -> SplitId {
214        let id = SplitId(self.next_split);
215        self.next_split += 1;
216        id
217    }
218
219    /// Adds `panel` to the tree relative to `target`.
220    ///
221    /// `Center` joins `target`'s tab group; the edge zones split it. A panel
222    /// already in the tree is moved rather than duplicated, which is what makes
223    /// this the single entry point for a drag-and-drop rearrange.
224    ///
225    /// With no `target` — or a `target` that isn't in the tree — the panel
226    /// splits the root, or becomes the root if the dock is empty.
227    ///
228    /// Returns the [`SplitId`] of the divider this created, so a caller
229    /// building a default layout can set its ratio without having to guess at
230    /// allocation order. `None` when the panel joined a tab group instead.
231    pub fn insert(
232        &mut self,
233        panel: impl Into<String>,
234        target: Option<&str>,
235        zone: DropZone,
236    ) -> Option<SplitId> {
237        let panel = panel.into();
238        self.remove(&panel);
239
240        let Some(root) = self.root.take() else {
241            self.root = Some(DockNode::Tabs {
242                panels: vec![panel],
243                active: 0,
244            });
245            return None;
246        };
247
248        let new_group = DockNode::Tabs {
249            panels: vec![panel.clone()],
250            active: 0,
251        };
252
253        // Resolve the target group first: after `remove` the tree may have
254        // collapsed, and a target that no longer exists must fall back to the
255        // root rather than silently drop the panel.
256        let target_exists = target.is_some_and(|t| node_contains(&root, t));
257
258        let split_id = self.alloc_split();
259        self.root = Some(if target_exists {
260            let target = target.expect("checked by target_exists");
261            insert_at(root, target, new_group, zone, split_id)
262        } else {
263            match zone.axis() {
264                None => {
265                    // No target and no axis: join the first group we find, so a
266                    // centre-drop onto empty space still places the panel.
267                    push_into_first_group(root, &panel)
268                }
269                Some(axis) => make_split(split_id, axis, root, new_group, zone),
270            }
271        });
272
273        // Every path that had an axis produced exactly one new split.
274        zone.axis().map(|_| split_id)
275    }
276
277    /// Removes `panel`. An emptied tab group collapses, and its parent split is
278    /// replaced by the surviving sibling so no blank area is left behind.
279    ///
280    /// Returns whether the panel was there.
281    pub fn remove(&mut self, panel: &str) -> bool {
282        let Some(root) = self.root.take() else {
283            return false;
284        };
285        let (node, removed) = remove_from(root, panel);
286        self.root = node;
287        removed
288    }
289
290    /// Makes `panel` the visible tab of its group. No-op if it isn't in the
291    /// tree.
292    pub fn activate(&mut self, panel: &str) -> bool {
293        self.root
294            .as_mut()
295            .is_some_and(|root| activate_in(root, panel))
296    }
297
298    /// Sets a split's ratio, clamped to `0.05..=0.95` so a divider dragged to
299    /// the edge leaves the pane recoverable.
300    pub fn set_ratio(&mut self, id: SplitId, ratio: f32) -> bool {
301        self.root
302            .as_mut()
303            .is_some_and(|root| set_ratio_in(root, id, ratio.clamp(0.05, 0.95)))
304    }
305
306    /// Places every group and divider inside `area`.
307    pub fn layout(&self, area: DockRect) -> DockLayout {
308        let mut out = DockLayout::default();
309        if let Some(root) = &self.root {
310            layout_node(root, area, &mut out);
311        }
312        out
313    }
314}
315
316// ── Free functions: the recursion, kept out of the impl so each rule reads on
317//    its own ────────────────────────────────────────────────────────────────
318
319fn collect_panels(node: &DockNode, out: &mut Vec<String>) {
320    match node {
321        DockNode::Tabs { panels, .. } => out.extend(panels.iter().cloned()),
322        DockNode::Split { first, second, .. } => {
323            collect_panels(first, out);
324            collect_panels(second, out);
325        }
326    }
327}
328
329fn node_contains(node: &DockNode, panel: &str) -> bool {
330    match node {
331        DockNode::Tabs { panels, .. } => panels.iter().any(|p| p == panel),
332        DockNode::Split { first, second, .. } => {
333            node_contains(first, panel) || node_contains(second, panel)
334        }
335    }
336}
337
338fn make_split(
339    id: SplitId,
340    axis: SplitAxis,
341    existing: DockNode,
342    incoming: DockNode,
343    zone: DropZone,
344) -> DockNode {
345    let (first, second) = if zone.takes_first() {
346        (incoming, existing)
347    } else {
348        (existing, incoming)
349    };
350    DockNode::Split {
351        id,
352        axis,
353        ratio: 0.5,
354        first: Box::new(first),
355        second: Box::new(second),
356    }
357}
358
359/// Adds `panel` to the first tab group found, depth-first.
360fn push_into_first_group(node: DockNode, panel: &str) -> DockNode {
361    match node {
362        DockNode::Tabs { mut panels, .. } => {
363            panels.push(panel.to_owned());
364            let active = panels.len() - 1;
365            DockNode::Tabs { panels, active }
366        }
367        DockNode::Split {
368            id,
369            axis,
370            ratio,
371            first,
372            second,
373        } => DockNode::Split {
374            id,
375            axis,
376            ratio,
377            first: Box::new(push_into_first_group(*first, panel)),
378            second,
379        },
380    }
381}
382
383fn insert_at(
384    node: DockNode,
385    target: &str,
386    incoming: DockNode,
387    zone: DropZone,
388    split_id: SplitId,
389) -> DockNode {
390    match node {
391        DockNode::Tabs { mut panels, active } => {
392            if !panels.iter().any(|p| p == target) {
393                return DockNode::Tabs { panels, active };
394            }
395            match zone.axis() {
396                None => {
397                    // Joining the group: the dropped panel becomes active, so
398                    // the user sees what they just moved.
399                    if let DockNode::Tabs {
400                        panels: incoming_panels,
401                        ..
402                    } = incoming
403                    {
404                        panels.extend(incoming_panels);
405                    }
406                    let active = panels.len().saturating_sub(1);
407                    DockNode::Tabs { panels, active }
408                }
409                Some(axis) => make_split(
410                    split_id,
411                    axis,
412                    DockNode::Tabs { panels, active },
413                    incoming,
414                    zone,
415                ),
416            }
417        }
418        DockNode::Split {
419            id,
420            axis,
421            ratio,
422            first,
423            second,
424        } => {
425            if node_contains(&first, target) {
426                DockNode::Split {
427                    id,
428                    axis,
429                    ratio,
430                    first: Box::new(insert_at(*first, target, incoming, zone, split_id)),
431                    second,
432                }
433            } else {
434                DockNode::Split {
435                    id,
436                    axis,
437                    ratio,
438                    first,
439                    second: Box::new(insert_at(*second, target, incoming, zone, split_id)),
440                }
441            }
442        }
443    }
444}
445
446fn remove_from(node: DockNode, panel: &str) -> (Option<DockNode>, bool) {
447    match node {
448        DockNode::Tabs { mut panels, active } => {
449            let Some(pos) = panels.iter().position(|p| p == panel) else {
450                return (Some(DockNode::Tabs { panels, active }), false);
451            };
452            panels.remove(pos);
453            if panels.is_empty() {
454                return (None, true);
455            }
456            // Keep the neighbour selected rather than snapping to the first
457            // tab: closing one of several tabs should leave you next to where
458            // you were.
459            let active = active.min(panels.len() - 1);
460            (Some(DockNode::Tabs { panels, active }), true)
461        }
462        DockNode::Split {
463            id,
464            axis,
465            ratio,
466            first,
467            second,
468        } => {
469            let (first, removed_first) = remove_from(*first, panel);
470            let (second, removed) = if removed_first {
471                (Some(*second), true)
472            } else {
473                let (s, r) = remove_from(*second, panel);
474                (s, r)
475            };
476            match (first, second) {
477                (Some(f), Some(s)) => (
478                    Some(DockNode::Split {
479                        id,
480                        axis,
481                        ratio,
482                        first: Box::new(f),
483                        second: Box::new(s),
484                    }),
485                    removed,
486                ),
487                // A split with one surviving child is not a split any more.
488                (Some(only), None) | (None, Some(only)) => (Some(only), removed),
489                (None, None) => (None, removed),
490            }
491        }
492    }
493}
494
495fn activate_in(node: &mut DockNode, panel: &str) -> bool {
496    match node {
497        DockNode::Tabs { panels, active } => {
498            if let Some(pos) = panels.iter().position(|p| p == panel) {
499                *active = pos;
500                true
501            } else {
502                false
503            }
504        }
505        DockNode::Split { first, second, .. } => {
506            activate_in(first, panel) || activate_in(second, panel)
507        }
508    }
509}
510
511fn set_ratio_in(node: &mut DockNode, target: SplitId, value: f32) -> bool {
512    match node {
513        DockNode::Tabs { .. } => false,
514        DockNode::Split {
515            id,
516            ratio,
517            first,
518            second,
519            ..
520        } => {
521            if *id == target {
522                *ratio = value;
523                return true;
524            }
525            set_ratio_in(first, target, value) || set_ratio_in(second, target, value)
526        }
527    }
528}
529
530fn layout_node(node: &DockNode, area: DockRect, out: &mut DockLayout) {
531    match node {
532        DockNode::Tabs { panels, active } => out.groups.push(TabGroupLayout {
533            rect: area,
534            panels: panels.clone(),
535            active: *active,
536        }),
537        DockNode::Split {
538            id,
539            axis,
540            ratio,
541            first,
542            second,
543        } => {
544            let [x, y, w, h] = area;
545            let half = SPLITTER_THICKNESS * 0.5;
546            match axis {
547                SplitAxis::Horizontal => {
548                    // Clamp in pixels, not in ratio: the same ratio means a
549                    // different width at every window size, so a ratio-only
550                    // clamp cannot promise a usable pane.
551                    let cut = (w * ratio).clamp(MIN_PANE.min(w * 0.5), (w - MIN_PANE).max(w * 0.5));
552                    layout_node(first, [x, y, (cut - half).max(0.0), h], out);
553                    layout_node(
554                        second,
555                        [x + cut + half, y, (w - cut - half).max(0.0), h],
556                        out,
557                    );
558                    out.splitters.push(SplitterLayout {
559                        id: *id,
560                        rect: [x + cut - half, y, SPLITTER_THICKNESS, h],
561                        axis: *axis,
562                        bounds: area,
563                    });
564                }
565                SplitAxis::Vertical => {
566                    let cut = (h * ratio).clamp(MIN_PANE.min(h * 0.5), (h - MIN_PANE).max(h * 0.5));
567                    layout_node(first, [x, y, w, (cut - half).max(0.0)], out);
568                    layout_node(
569                        second,
570                        [x, y + cut + half, w, (h - cut - half).max(0.0)],
571                        out,
572                    );
573                    out.splitters.push(SplitterLayout {
574                        id: *id,
575                        rect: [x, y + cut - half, w, SPLITTER_THICKNESS],
576                        axis: *axis,
577                        bounds: area,
578                    });
579                }
580            }
581        }
582    }
583}
584
585/// Fraction of the shorter side taken by each edge drop band.
586///
587/// Kept under `0.146`, the point where the four bands would together claim half
588/// the area: joining a tab group is the common intent and must stay the easy
589/// target, while a split is the deliberate one and only needs a band wide
590/// enough to hit without aiming (48 px on a 400 px pane).
591const EDGE_BAND: f32 = 0.12;
592
593/// Classifies a pointer position inside `rect` into a drop zone.
594///
595/// The centre keeps the majority of the area on purpose: joining a tab group is
596/// the common intent, and a split is the deliberate one. Bands are measured
597/// against the shorter side so a long thin panel doesn't turn almost entirely
598/// into edge.
599pub fn zone_at(rect: DockRect, pointer: [f32; 2]) -> Option<DropZone> {
600    let [x, y, w, h] = rect;
601    if w <= 0.0 || h <= 0.0 {
602        return None;
603    }
604    let (px, py) = (pointer[0] - x, pointer[1] - y);
605    if px < 0.0 || py < 0.0 || px > w || py > h {
606        return None;
607    }
608
609    let band = (w.min(h) * EDGE_BAND).max(1.0);
610    // Distance to each edge; the smallest wins, so corners resolve to whichever
611    // edge the pointer is genuinely nearer rather than to a fixed precedence.
612    let (left, right, top, bottom) = (px, w - px, py, h - py);
613    let nearest = left.min(right).min(top).min(bottom);
614    if nearest > band {
615        return Some(DropZone::Center);
616    }
617    Some(if nearest == left {
618        DropZone::Left
619    } else if nearest == right {
620        DropZone::Right
621    } else if nearest == top {
622        DropZone::Top
623    } else {
624        DropZone::Bottom
625    })
626}
627
628/// Turns a pointer position on a splitter into the ratio it implies.
629///
630/// Lives next to [`DockTree::set_ratio`] so the geometry the layout used and
631/// the geometry the drag inverts cannot drift apart.
632pub fn ratio_from_pointer(splitter: &SplitterLayout, pointer: [f32; 2]) -> f32 {
633    let [bx, by, bw, bh] = splitter.bounds;
634    let raw = match splitter.axis {
635        SplitAxis::Horizontal if bw > 0.0 => (pointer[0] - bx) / bw,
636        SplitAxis::Vertical if bh > 0.0 => (pointer[1] - by) / bh,
637        _ => 0.5,
638    };
639    raw.clamp(0.05, 0.95)
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    const AREA: DockRect = [0.0, 0.0, 1000.0, 800.0];
647
648    fn tree_of(panels: &[&str]) -> DockTree {
649        let mut t = DockTree::single(panels[0]);
650        for p in &panels[1..] {
651            t.insert(*p, Some(panels[0]), DropZone::Center);
652        }
653        t
654    }
655
656    #[test]
657    fn single_panel_fills_the_area() {
658        let t = DockTree::single("viewport");
659        let l = t.layout(AREA);
660        assert_eq!(l.groups.len(), 1);
661        assert_eq!(l.groups[0].rect, AREA);
662        assert!(l.splitters.is_empty(), "one group needs no divider");
663    }
664
665    /// A centre drop joins the group; an edge drop splits it. This is the whole
666    /// interaction in one assertion pair.
667    #[test]
668    fn centre_joins_and_edge_splits() {
669        let mut joined = DockTree::single("a");
670        joined.insert("b", Some("a"), DropZone::Center);
671        assert_eq!(joined.layout(AREA).groups.len(), 1);
672
673        let mut split = DockTree::single("a");
674        split.insert("b", Some("a"), DropZone::Right);
675        let l = split.layout(AREA);
676        assert_eq!(l.groups.len(), 2);
677        assert_eq!(l.splitters.len(), 1);
678    }
679
680    /// `Left`/`Top` put the dropped panel first, `Right`/`Bottom` put it second
681    /// — otherwise a drop lands on the opposite side from the one shown.
682    #[test]
683    fn drop_side_decides_order() {
684        let mut left = DockTree::single("a");
685        left.insert("b", Some("a"), DropZone::Left);
686        let l = left.layout(AREA);
687        assert_eq!(l.groups[0].panels, vec!["b"]);
688        assert_eq!(l.groups[1].panels, vec!["a"]);
689
690        let mut bottom = DockTree::single("a");
691        bottom.insert("b", Some("a"), DropZone::Bottom);
692        let l = bottom.layout(AREA);
693        assert_eq!(l.groups[0].panels, vec!["a"]);
694        assert_eq!(l.groups[1].panels, vec!["b"]);
695        assert_eq!(l.splitters[0].axis, SplitAxis::Vertical);
696    }
697
698    /// Re-inserting a panel moves it. Without the implicit remove, dragging a
699    /// tab elsewhere would leave a copy behind.
700    #[test]
701    fn insert_moves_rather_than_duplicates() {
702        let mut t = tree_of(&["a", "b", "c"]);
703        t.insert("b", Some("a"), DropZone::Right);
704        let panels = t.panels();
705        assert_eq!(panels.iter().filter(|p| *p == "b").count(), 1);
706        assert_eq!(panels.len(), 3);
707    }
708
709    /// Emptying a group collapses its split, so no blank area survives.
710    #[test]
711    fn removing_last_tab_collapses_the_split() {
712        let mut t = DockTree::single("a");
713        t.insert("b", Some("a"), DropZone::Right);
714        assert_eq!(t.layout(AREA).splitters.len(), 1);
715
716        assert!(t.remove("b"));
717        let l = t.layout(AREA);
718        assert_eq!(l.groups.len(), 1);
719        assert!(l.splitters.is_empty(), "the divider must go with the pane");
720        assert_eq!(l.groups[0].rect, AREA, "the survivor takes the whole area");
721    }
722
723    #[test]
724    fn removing_the_only_panel_empties_the_dock() {
725        let mut t = DockTree::single("a");
726        assert!(t.remove("a"));
727        assert!(t.is_empty());
728        assert!(t.layout(AREA).groups.is_empty());
729        assert!(!t.remove("a"), "removing twice reports nothing removed");
730    }
731
732    /// Closing a tab keeps the neighbour selected instead of snapping to the
733    /// first one.
734    #[test]
735    fn closing_a_tab_keeps_a_valid_neighbour_active() {
736        let mut t = tree_of(&["a", "b", "c"]);
737        t.activate("c");
738        assert!(t.remove("c"));
739        let g = &t.layout(AREA).groups[0];
740        assert!(g.active < g.panels.len(), "active index must stay in range");
741        assert_eq!(g.active_panel(), Some("b"));
742    }
743
744    #[test]
745    fn ratio_clamps_so_a_pane_never_disappears() {
746        let mut t = DockTree::single("a");
747        t.insert("b", Some("a"), DropZone::Right);
748        let id = t.layout(AREA).splitters[0].id;
749
750        t.set_ratio(id, -5.0);
751        let l = t.layout(AREA);
752        assert!(l.groups[0].rect[2] >= MIN_PANE * 0.5, "left pane survives");
753
754        t.set_ratio(id, 99.0);
755        let l = t.layout(AREA);
756        assert!(l.groups[1].rect[2] >= MIN_PANE * 0.5, "right pane survives");
757    }
758
759    /// Splitting twice nests, and both dividers stay addressable — a resize
760    /// must not be able to grab the wrong one.
761    #[test]
762    fn nested_splits_keep_distinct_ids() {
763        let mut t = DockTree::single("a");
764        t.insert("b", Some("a"), DropZone::Right);
765        t.insert("c", Some("b"), DropZone::Bottom);
766        let l = t.layout(AREA);
767        assert_eq!(l.groups.len(), 3);
768        assert_eq!(l.splitters.len(), 2);
769        assert_ne!(l.splitters[0].id, l.splitters[1].id);
770    }
771
772    /// Panes tile the area without overlapping — the property that makes the
773    /// layout usable at all.
774    #[test]
775    fn panes_do_not_overlap() {
776        let mut t = DockTree::single("a");
777        t.insert("b", Some("a"), DropZone::Right);
778        t.insert("c", Some("b"), DropZone::Bottom);
779        let groups = t.layout(AREA).groups;
780        for (i, g) in groups.iter().enumerate() {
781            for other in &groups[i + 1..] {
782                let sep_x = g.rect[0] + g.rect[2] <= other.rect[0] + 0.01
783                    || other.rect[0] + other.rect[2] <= g.rect[0] + 0.01;
784                let sep_y = g.rect[1] + g.rect[3] <= other.rect[1] + 0.01
785                    || other.rect[1] + other.rect[3] <= g.rect[1] + 0.01;
786                assert!(sep_x || sep_y, "{:?} overlaps {:?}", g.rect, other.rect);
787            }
788        }
789    }
790
791    #[test]
792    fn zone_at_reads_the_centre_and_the_four_edges() {
793        let r = [0.0, 0.0, 400.0, 400.0];
794        assert_eq!(zone_at(r, [200.0, 200.0]), Some(DropZone::Center));
795        assert_eq!(zone_at(r, [5.0, 200.0]), Some(DropZone::Left));
796        assert_eq!(zone_at(r, [395.0, 200.0]), Some(DropZone::Right));
797        assert_eq!(zone_at(r, [200.0, 5.0]), Some(DropZone::Top));
798        assert_eq!(zone_at(r, [200.0, 395.0]), Some(DropZone::Bottom));
799        assert_eq!(zone_at(r, [-1.0, 200.0]), None, "outside is not a zone");
800    }
801
802    /// The centre keeps most of the area: joining a group is the common
803    /// intent, splitting is the deliberate one.
804    #[test]
805    fn centre_zone_dominates_the_area() {
806        let r = [0.0, 0.0, 400.0, 400.0];
807        let mut centre = 0;
808        let mut total = 0;
809        for gx in 0..40 {
810            for gy in 0..40 {
811                total += 1;
812                if zone_at(r, [gx as f32 * 10.0 + 5.0, gy as f32 * 10.0 + 5.0])
813                    == Some(DropZone::Center)
814                {
815                    centre += 1;
816                }
817            }
818        }
819        assert!(
820            centre * 2 > total,
821            "centre should own the majority ({centre}/{total})"
822        );
823    }
824
825    /// A drag inverts the same geometry the layout produced.
826    #[test]
827    fn ratio_from_pointer_inverts_the_layout() {
828        let mut t = DockTree::single("a");
829        t.insert("b", Some("a"), DropZone::Right);
830        let sp = t.layout(AREA).splitters[0].clone();
831        let ratio = ratio_from_pointer(&sp, [250.0, 400.0]);
832        assert!((ratio - 0.25).abs() < 1e-4, "got {ratio}");
833
834        t.set_ratio(sp.id, ratio);
835        let l = t.layout(AREA);
836        assert!(
837            (l.groups[0].rect[2] - 247.0).abs() < 4.0,
838            "left pane follows"
839        );
840    }
841
842    /// A target that vanished (its group collapsed as part of the same move)
843    /// must not swallow the panel.
844    #[test]
845    fn insert_with_unknown_target_still_places_the_panel() {
846        let mut t = DockTree::single("a");
847        t.insert("b", Some("ghost"), DropZone::Right);
848        assert!(t.contains("b"));
849        assert_eq!(t.panels().len(), 2);
850
851        let mut t2 = DockTree::single("a");
852        t2.insert("b", None, DropZone::Center);
853        assert!(t2.contains("b"), "a centre drop with no target still lands");
854    }
855
856    /// The tree round-trips through RON, which is what layout persistence will
857    /// rely on.
858    #[test]
859    fn tree_round_trips_through_serde() {
860        let mut t = DockTree::single("a");
861        t.insert("b", Some("a"), DropZone::Right);
862        t.insert("c", Some("b"), DropZone::Bottom);
863        t.set_ratio(t.layout(AREA).splitters[0].id, 0.3);
864
865        let encoded = serde_json::to_string(&t).expect("serialize");
866        let decoded: DockTree = serde_json::from_str(&encoded).expect("deserialize");
867        assert_eq!(decoded, t);
868        assert_eq!(decoded.layout(AREA), t.layout(AREA));
869    }
870}