1use serde::{Deserialize, Serialize};
28
29pub type DockRect = [f32; 4];
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum SplitAxis {
36 Horizontal,
38 Vertical,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DropZone {
45 Center,
47 Left,
49 Right,
51 Top,
53 Bottom,
55}
56
57impl DropZone {
58 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 pub const fn takes_first(self) -> bool {
72 matches!(self, DropZone::Left | DropZone::Top)
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79pub struct SplitId(pub u32);
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub enum DockNode {
84 Tabs {
88 panels: Vec<String>,
90 active: usize,
92 },
93 Split {
95 id: SplitId,
97 axis: SplitAxis,
99 ratio: f32,
101 first: Box<DockNode>,
103 second: Box<DockNode>,
105 },
106}
107
108pub const SPLITTER_THICKNESS: f32 = 6.0;
111
112pub const MIN_PANE: f32 = 120.0;
116
117#[derive(Debug, Clone, PartialEq)]
119pub struct TabGroupLayout {
120 pub rect: DockRect,
122 pub panels: Vec<String>,
124 pub active: usize,
126}
127
128impl TabGroupLayout {
129 pub fn active_panel(&self) -> Option<&str> {
131 self.panels.get(self.active).map(|s| s.as_str())
132 }
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct SplitterLayout {
138 pub id: SplitId,
140 pub rect: DockRect,
142 pub axis: SplitAxis,
144 pub bounds: DockRect,
147}
148
149#[derive(Debug, Clone, Default, PartialEq)]
151pub struct DockLayout {
152 pub groups: Vec<TabGroupLayout>,
154 pub splitters: Vec<SplitterLayout>,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct DockTree {
161 root: Option<DockNode>,
162 next_split: u32,
166}
167
168impl Default for DockTree {
169 fn default() -> Self {
170 Self::empty()
171 }
172}
173
174impl DockTree {
175 pub fn empty() -> Self {
177 Self {
178 root: None,
179 next_split: 0,
180 }
181 }
182
183 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 pub fn is_empty(&self) -> bool {
196 self.root.is_none()
197 }
198
199 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 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 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 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 push_into_first_group(root, &panel)
268 }
269 Some(axis) => make_split(split_id, axis, root, new_group, zone),
270 }
271 });
272
273 zone.axis().map(|_| split_id)
275 }
276
277 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 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 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 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
316fn 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
359fn 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 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 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 (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 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
585const EDGE_BAND: f32 = 0.12;
592
593pub 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 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
628pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}