Skip to main content

repose_docking/
lib.rs

1#![allow(non_snake_case)]
2
3use std::any::Any;
4use std::cell::RefCell;
5use std::collections::HashMap;
6use std::rc::Rc;
7
8use repose_core::*;
9use repose_ui::*;
10
11pub type PanelId = u64;
12
13#[derive(Clone)]
14pub struct DockPanel {
15    pub id: PanelId,
16    pub title: String,
17    pub content: Rc<dyn Fn() -> View>,
18}
19
20#[derive(Clone, Default)]
21pub struct DockCallbacks {
22    /// Optional popout handler. If provided, the docking system will call it
23    /// when a panel is dropped on the "float" target or when user taps popout.
24    pub on_popout: Option<Rc<dyn Fn(PanelId)>>,
25
26    /// Optional close handler (tab close button).
27    pub on_close: Option<Rc<dyn Fn(PanelId)>>,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum SplitDir {
32    Horizontal, // left/right
33    Vertical,   // top/bottom
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum DropZone {
38    Center,
39    Left,
40    Right,
41    Top,
42    Bottom,
43    Float,
44}
45
46/// Persistent docking state.
47/// Store this in `remember_state_with_key(...)` or `SavedState` etc.
48#[derive(Clone)]
49pub struct DockState {
50    pub root: DockNode,
51    next_id: u64,
52}
53
54#[derive(Clone)]
55pub struct DockNode {
56    pub id: u64,
57    pub kind: DockKind,
58}
59
60#[derive(Clone)]
61pub enum DockKind {
62    Empty,
63    Tabs {
64        tabs: Vec<PanelId>,
65        active: Option<PanelId>,
66    },
67    Split {
68        dir: SplitDir,
69        ratio: f32, // 0..1
70        a: Box<DockNode>,
71        b: Box<DockNode>,
72    },
73}
74
75impl DockState {
76    pub fn new_with_tabs(tabs: Vec<PanelId>) -> Self {
77        let mut st = Self {
78            root: DockNode {
79                id: 1,
80                kind: DockKind::Empty,
81            },
82            next_id: 2,
83        };
84        st.root.kind = DockKind::Tabs { tabs, active: None };
85        st.normalize();
86        st
87    }
88
89    /// Create a DockState from a pre-built root node.
90    /// The `max_node_id` should be higher than any node ID used in the tree.
91    pub fn from_root(root: DockNode, max_node_id: u64) -> Self {
92        let mut st = Self {
93            root,
94            next_id: max_node_id + 1,
95        };
96        st.normalize();
97        st
98    }
99
100    fn alloc_id(&mut self) -> u64 {
101        let id = self.next_id;
102        self.next_id += 1;
103        id
104    }
105
106    pub fn normalize(&mut self) {
107        normalize_node(&mut self.root);
108    }
109
110    /// Remove panel without normalizing - for use in compound operations
111    pub fn remove_panel_no_normalize(&mut self, pid: PanelId) -> bool {
112        remove_panel_in_node(&mut self.root, pid)
113    }
114
115    pub fn remove_panel(&mut self, pid: PanelId) -> bool {
116        let removed = remove_panel_in_node(&mut self.root, pid);
117        if removed {
118            normalize_node(&mut self.root);
119        }
120        removed
121    }
122
123    pub fn set_active(&mut self, tabs_node_id: u64, pid: PanelId) {
124        if let Some(n) = find_node_mut(&mut self.root, tabs_node_id) {
125            if let DockKind::Tabs { tabs, active } = &mut n.kind {
126                if tabs.contains(&pid) {
127                    *active = Some(pid);
128                }
129            }
130        }
131    }
132
133    pub fn set_split_ratio(&mut self, split_node_id: u64, ratio: f32) {
134        let ratio = ratio.clamp(0.05, 0.95);
135        if let Some(n) = find_node_mut(&mut self.root, split_node_id) {
136            if let DockKind::Split { ratio: r, .. } = &mut n.kind {
137                *r = ratio;
138            }
139        }
140    }
141
142    pub fn dock_panel(&mut self, target_node_id: u64, zone: DropZone, pid: PanelId) -> bool {
143        self.remove_panel_no_normalize(pid);
144
145        let result = match zone {
146            DropZone::Center => self.insert_as_tab(target_node_id, pid),
147            DropZone::Left | DropZone::Right | DropZone::Top | DropZone::Bottom => {
148                self.insert_as_split(target_node_id, zone, pid)
149            }
150            DropZone::Float => false,
151        };
152
153        self.normalize();
154        result
155    }
156
157    fn insert_as_tab(&mut self, target_node_id: u64, pid: PanelId) -> bool {
158        let Some(n) = find_node_mut(&mut self.root, target_node_id) else {
159            return false;
160        };
161
162        match &mut n.kind {
163            DockKind::Tabs { tabs, active } => {
164                if !tabs.contains(&pid) {
165                    tabs.push(pid);
166                }
167                *active = Some(pid);
168                self.normalize();
169                true
170            }
171            DockKind::Empty => {
172                n.kind = DockKind::Tabs {
173                    tabs: vec![pid],
174                    active: Some(pid),
175                };
176                self.normalize();
177                true
178            }
179            DockKind::Split { .. } => false,
180        }
181    }
182
183    fn insert_as_split(&mut self, target_node_id: u64, zone: DropZone, pid: PanelId) -> bool {
184        // Allocate all IDs upfront before borrowing
185        let new_tabs_id = self.alloc_id();
186        let new_split_id = self.alloc_id();
187
188        let Some(n) = find_node_mut(&mut self.root, target_node_id) else {
189            return false;
190        };
191
192        let old_kind = std::mem::replace(&mut n.kind, DockKind::Empty);
193
194        let dir = match zone {
195            DropZone::Left | DropZone::Right => SplitDir::Horizontal,
196            DropZone::Top | DropZone::Bottom => SplitDir::Vertical,
197            _ => SplitDir::Horizontal,
198        };
199
200        let new_tabs = DockNode {
201            id: new_tabs_id,
202            kind: DockKind::Tabs {
203                tabs: vec![pid],
204                active: Some(pid),
205            },
206        };
207
208        // Old content KEEPS the original target_node_id
209        let old_node = DockNode {
210            id: target_node_id,
211            kind: old_kind,
212        };
213
214        let (a, b) = match zone {
215            DropZone::Left | DropZone::Top => (Box::new(new_tabs), Box::new(old_node)),
216            DropZone::Right | DropZone::Bottom => (Box::new(old_node), Box::new(new_tabs)),
217            _ => (Box::new(old_node), Box::new(new_tabs)),
218        };
219
220        // The node at this position becomes a split with a NEW ID
221        n.id = new_split_id;
222        n.kind = DockKind::Split {
223            dir,
224            ratio: 0.5,
225            a,
226            b,
227        };
228
229        self.normalize();
230        true
231    }
232}
233
234#[derive(Clone, Debug)]
235pub struct DockTabPayload {
236    pub panel_id: PanelId,
237}
238
239#[derive(Clone, Debug, PartialEq, Eq)]
240struct HoverHint {
241    node_id: u64,
242    zone: DropZone,
243}
244
245#[derive(Clone)]
246struct SplitDrag {
247    node_id: u64,
248    dir: SplitDir,
249}
250
251pub fn DockArea(
252    key: impl Into<String>,
253    modifier: Modifier,
254    state: Rc<RefCell<DockState>>,
255    panels: Vec<DockPanel>,
256    callbacks: DockCallbacks,
257) -> View {
258    let key = key.into();
259    let registry = Rc::new(build_registry(panels));
260
261    // Ephemeral UI state (per DockArea)
262    let hover_sig = remember_with_key(format!("dock:hover:{key}"), || signal(None::<HoverHint>));
263    let drag_active = remember_with_key(format!("dock:drag_active:{key}"), || signal(false));
264    let split_hover = remember_with_key(format!("dock:split_hover:{key}"), || signal(None::<u64>));
265    let split_drag = remember_with_key(format!("dock:split_drag:{key}"), || {
266        RefCell::new(None::<SplitDrag>)
267    });
268
269    // Outer "float" drop target: if you drop a tab anywhere not handled by inner targets.
270    // We set z-index low so inner targets win.
271    let float_target = {
272        let state = state.clone();
273        let hover_sig = hover_sig.clone();
274        let cb_pop = callbacks.on_popout.clone();
275
276        Box(Modifier::new()
277            .fill_max_size()
278            .z_index(-1000.0)
279            .on_drop(move |ev| {
280                // Only accept docking payloads
281                let Some(p) = ev.payload.as_ref().downcast_ref::<DockTabPayload>() else {
282                    return false;
283                };
284                let Some(pop) = cb_pop.as_ref() else {
285                    return false;
286                };
287
288                // Remove from dock tree, then pop out
289                state.borrow_mut().remove_panel(p.panel_id);
290                pop(p.panel_id);
291
292                hover_sig.set(None);
293                true
294            }))
295    };
296
297    // Actual docking UI
298    let root_view = {
299        let st = state.borrow().clone();
300        render_node(
301            &st.root,
302            &registry,
303            &state,
304            &callbacks,
305            &hover_sig,
306            &drag_active,
307            &split_hover,
308            &split_drag,
309            key.as_str(),
310        )
311    };
312
313    Stack(modifier.fill_max_size()).child((
314        Box(Modifier::new()
315            .absolute()
316            .offset(Some(0.0), Some(0.0), Some(0.0), Some(0.0)))
317        .child(float_target),
318        Box(Modifier::new()
319            .absolute()
320            .offset(Some(0.0), Some(0.0), Some(0.0), Some(0.0)))
321        .child(root_view),
322    ))
323}
324
325fn build_registry(panels: Vec<DockPanel>) -> HashMap<PanelId, DockPanel> {
326    let mut m = HashMap::new();
327    for p in panels {
328        m.insert(p.id, p);
329    }
330    m
331}
332
333fn render_node(
334    node: &DockNode,
335    registry: &Rc<HashMap<PanelId, DockPanel>>,
336    state: &Rc<RefCell<DockState>>,
337    callbacks: &DockCallbacks,
338    hover_sig: &Signal<Option<HoverHint>>,
339    drag_active: &Signal<bool>,
340    split_hover: &Signal<Option<u64>>,
341    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
342    key_prefix: &str,
343) -> View {
344    match &node.kind {
345        DockKind::Empty => Surface(
346            Modifier::new()
347                .fill_max_size()
348                .background(theme().surface)
349                .key(node.id),
350            Box(Modifier::new().fill_max_size()).child(Text("Empty").color(theme().on_surface)),
351        ),
352
353        DockKind::Tabs { tabs, active } => render_tabs(
354            node.id,
355            tabs,
356            *active,
357            registry,
358            state,
359            callbacks,
360            hover_sig,
361            drag_active,
362            split_hover,
363            key_prefix,
364        ),
365
366        DockKind::Split { dir, ratio, a, b } => render_split(
367            node.id,
368            *dir,
369            *ratio,
370            a,
371            b,
372            registry,
373            state,
374            callbacks,
375            hover_sig,
376            drag_active,
377            split_hover,
378            split_drag,
379            key_prefix,
380        ),
381    }
382}
383
384fn render_tabs(
385    node_id: u64,
386    tabs: &Vec<PanelId>,
387    active: Option<PanelId>,
388    registry: &Rc<HashMap<PanelId, DockPanel>>,
389    state: &Rc<RefCell<DockState>>,
390    callbacks: &DockCallbacks,
391    hover_sig: &Signal<Option<HoverHint>>,
392    drag_active: &Signal<bool>,
393    _split_hover: &Signal<Option<u64>>,
394    key_prefix: &str,
395) -> View {
396    let th = theme();
397
398    // Ensure active is valid
399    let active_pid = active.or_else(|| tabs.first().copied());
400
401    let tabbar_rect = remember_with_key(format!("dock:tabbar_rect:{key_prefix}:{node_id}"), || {
402        RefCell::new(Rect::default())
403    });
404
405    let mut bar_mod = Modifier::new()
406        .fill_max_width()
407        .height(40.0)
408        .background(th.surface)
409        .border(1.0, th.outline, 0.0)
410        .padding(6.0)
411        .painter({
412            let tabbar_rect = tabbar_rect.clone();
413            move |_scene, r| *tabbar_rect.borrow_mut() = r
414        });
415
416    if drag_active.get() {
417        bar_mod = bar_mod.on_drop({
418            let state = state.clone();
419            let tabbar_rect = tabbar_rect.clone();
420            let hover_sig = hover_sig.clone();
421            let drag_active = drag_active.clone();
422
423            move |ev| {
424                let Some(p) = ev.payload.as_ref().downcast_ref::<DockTabPayload>() else {
425                    return false;
426                };
427
428                let mut st = state.borrow_mut();
429
430                // Always rm WITHOUT normalizing to preserve node_id validity
431                st.remove_panel_no_normalize(p.panel_id);
432
433                let r = *tabbar_rect.borrow();
434                let t = if r.w > 1.0 {
435                    ((ev.position.x - r.x) / r.w).clamp(0.0, 1.0)
436                } else {
437                    1.0
438                };
439
440                if let Some(n) = find_node_mut(&mut st.root, node_id) {
441                    if matches!(n.kind, DockKind::Empty) {
442                        n.kind = DockKind::Tabs {
443                            tabs: Vec::new(),
444                            active: None,
445                        };
446                    }
447
448                    if let DockKind::Tabs { tabs, active } = &mut n.kind {
449                        tabs.retain(|&x| x != p.panel_id);
450                        let idx =
451                            ((t * (tabs.len() as f32 + 1.0)).floor() as usize).min(tabs.len());
452                        tabs.insert(idx, p.panel_id);
453                        *active = Some(p.panel_id);
454                    }
455                }
456
457                st.normalize();
458
459                hover_sig.set(None);
460                drag_active.set(false);
461                request_frame();
462                true
463            }
464        });
465    }
466
467    let tab_bar = Row(bar_mod).with_children(
468        tabs.iter()
469            .copied()
470            .filter_map(|pid| {
471                let panel = registry.get(&pid)?;
472                let is_active = Some(pid) == active_pid;
473
474                let state_set = state.clone();
475                let title = panel.title.clone();
476
477                let drag_pid = pid;
478
479                let cb_close = callbacks.on_close.clone();
480                let cb_pop = callbacks.on_popout.clone();
481
482                Some(
483                    Stack(
484                        Modifier::new()
485                            .key(pid)
486                            .height(32.0)
487                            .padding(4.0)
488                            .clip_rounded(8.0)
489                            .background(if is_active {
490                                th.primary.with_alpha(80)
491                            } else {
492                                th.surface
493                            })
494                            .border(1.0, th.outline, 8.0),
495                    )
496                    .child((
497                        Box(Modifier::new()
498                            .height(24.0)
499                            .offset(None, Some(4.0), None, None)
500                            .clickable()
501                            .cursor(CursorIcon::Grab)
502                            .on_pointer_down({
503                                let state_set = state_set.clone();
504                                move |_| {
505                                    state_set.borrow_mut().set_active(node_id, pid);
506                                    request_frame();
507                                }
508                            })
509                            .on_drag_start({
510                                let drag_active = drag_active.clone();
511                                move |_start| {
512                                    drag_active.set(true);
513                                    Some(Rc::new(DockTabPayload { panel_id: drag_pid })
514                                        as Rc<dyn Any>)
515                                }
516                            })
517                            .on_drag_end({
518                                let hover_sig = hover_sig.clone();
519                                let drag_active = drag_active.clone();
520                                move |_end| {
521                                    drag_active.set(false);
522                                    hover_sig.set(None);
523                                }
524                            }))
525                        .child(
526                            Row(Modifier::new().height(24.0))
527                                .child((Text(title).color(th.on_surface),)),
528                        ),
529                        Row(Modifier::new().absolute().height(24.0).offset(
530                            None,
531                            Some(4.0),
532                            Some(2.0),
533                            None,
534                        ))
535                        .child((
536                            if let Some(pop) = cb_pop {
537                                Button(Text("↗").size(12.0), move || pop(pid))
538                                    .modifier(Modifier::new().padding(2.0))
539                            } else {
540                                Box(Modifier::new())
541                            },
542                            if let Some(close) = cb_close {
543                                Button(Text("×").size(12.0), move || close(pid))
544                                    .modifier(Modifier::new().padding(2.0))
545                            } else {
546                                Box(Modifier::new())
547                            },
548                        )),
549                    )),
550                )
551            })
552            .collect::<Vec<_>>(),
553    );
554
555    // Content
556    let content = if let Some(pid) = active_pid {
557        if let Some(panel) = registry.get(&pid) {
558            (panel.content)()
559        } else {
560            Text("Missing panel").color(th.error)
561        }
562    } else {
563        Text("No tabs").color(th.on_surface)
564    };
565
566    // Drop zones overlay (always present; highlight only when hovered)
567    let overlay = dock_drop_overlay(node_id, state, hover_sig, drag_active, key_prefix);
568
569    let tab_h = 40.0;
570
571    Stack(Modifier::new().fill_max_size().key(node_id)).child((
572        Column(Modifier::new().fill_max_size()).child((
573            tab_bar,
574            Surface(
575                Modifier::new().fill_max_size().background(th.background),
576                Box(Modifier::new().fill_max_size().padding(8.0)).child(content),
577            ),
578        )),
579        Box(Modifier::new()
580            .absolute()
581            .offset(Some(0.0), Some(tab_h), Some(0.0), Some(0.0)))
582        .child(overlay),
583    ))
584}
585
586fn dock_drop_overlay(
587    node_id: u64,
588    state: &Rc<RefCell<DockState>>,
589    hover_sig: &Signal<Option<HoverHint>>,
590    drag_active: &Signal<bool>,
591    key_prefix: &str,
592) -> View {
593    let th = theme();
594    if !drag_active.get() {
595        return Box(Modifier::new());
596    }
597
598    let zone_dp = 48.0;
599
600    let hover = hover_sig.get();
601
602    let mk_zone = |zone: DropZone, m: Modifier| -> View {
603        let state2 = state.clone();
604        let hover2 = hover_sig.clone();
605
606        let label = match zone {
607            // Maybe have icons here later?
608            DropZone::Center => " ",
609            DropZone::Left => " ",
610            DropZone::Right => " ",
611            DropZone::Top => " ",
612            DropZone::Bottom => " ",
613            DropZone::Float => " ",
614        };
615
616        let highlight = if hover.as_ref() == Some(&HoverHint { node_id, zone }) {
617            Stack(
618                Modifier::new()
619                    .fill_max_size()
620                    .background(th.primary.with_alpha(51))
621                    .border(2.0, th.primary, 0.0),
622            )
623            .child(Text(label).size(12.0).color(th.on_primary))
624        } else {
625            Stack(
626                Modifier::new()
627                    .fill_max_size()
628                    .border(1.0, th.outline_variant, 0.0),
629            )
630            .child(Text(label).size(12.0).color(th.on_surface_variant))
631        };
632
633        Stack(
634            m.z_index(2000.0)
635                .key(hash_zone_key(node_id, zone))
636                .on_drag_enter({
637                    let hover2 = hover2.clone();
638                    move |_ev| {
639                        hover2.set(Some(HoverHint { node_id, zone }));
640                    }
641                })
642                .on_drag_over({
643                    let hover2 = hover2.clone();
644                    move |_ev| {
645                        hover2.set(Some(HoverHint { node_id, zone }));
646                    }
647                })
648                .on_drag_leave({
649                    let hover2 = hover2.clone();
650                    move |_ev| {
651                        // Only clear if we were hovering this
652                        if hover2.get().as_ref() == Some(&HoverHint { node_id, zone }) {
653                            hover2.set(None);
654                        }
655                    }
656                })
657                .on_drop(move |ev| {
658                    let Some(p) = ev.payload.as_ref().downcast_ref::<DockTabPayload>() else {
659                        return false;
660                    };
661
662                    let ok = state2.borrow_mut().dock_panel(node_id, zone, p.panel_id);
663                    hover2.set(None);
664                    request_frame();
665                    ok
666                }),
667        )
668        .child(highlight)
669    };
670
671    // Layout zones using absolute rects (no need for measured size):
672    // left/right/top/bottom thickness = zone_dp; center = remainder.
673    let left = mk_zone(
674        DropZone::Left,
675        Modifier::new()
676            .absolute()
677            .offset(Some(0.0), Some(0.0), None, Some(0.0))
678            .width(zone_dp),
679    );
680
681    let right = mk_zone(
682        DropZone::Right,
683        Modifier::new()
684            .absolute()
685            .offset(None, Some(0.0), Some(0.0), Some(0.0))
686            .width(zone_dp),
687    );
688
689    let top = mk_zone(
690        DropZone::Top,
691        Modifier::new()
692            .absolute()
693            .offset(Some(zone_dp), Some(0.0), Some(zone_dp), None)
694            .height(zone_dp),
695    );
696
697    let bottom = mk_zone(
698        DropZone::Bottom,
699        Modifier::new()
700            .absolute()
701            .offset(Some(zone_dp), None, Some(zone_dp), Some(0.0))
702            .height(zone_dp),
703    );
704
705    let center = mk_zone(
706        DropZone::Center,
707        Modifier::new().absolute().offset(
708            Some(zone_dp),
709            Some(zone_dp),
710            Some(zone_dp),
711            Some(zone_dp),
712        ),
713    );
714
715    Stack(
716        Modifier::new()
717            .fill_max_size()
718            .key(hash_str_key(key_prefix, node_id)),
719    )
720    .child((left, right, top, bottom, center))
721}
722
723fn render_split(
724    node_id: u64,
725    dir: SplitDir,
726    ratio: f32,
727    a: &DockNode,
728    b: &DockNode,
729    registry: &Rc<HashMap<PanelId, DockPanel>>,
730    state: &Rc<RefCell<DockState>>,
731    callbacks: &DockCallbacks,
732    hover_sig: &Signal<Option<HoverHint>>,
733    drag_active: &Signal<bool>,
734    split_hover: &Signal<Option<u64>>,
735    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
736    key_prefix: &str,
737) -> View {
738    let th = theme();
739    let ratio = ratio.clamp(0.05, 0.95);
740
741    // Track this split container rect so the divider can compute ratio from pointer position.
742    let rect_rc = remember_with_key(format!("dock:split_rect:{}:{node_id}", key_prefix), || {
743        RefCell::new(Rect::default())
744    });
745
746    // Paint-only hook to store rect
747    let track = {
748        let rect_rc = rect_rc.clone();
749        Modifier::new().painter(move |_scene, r| {
750            *rect_rc.borrow_mut() = r;
751        })
752    };
753
754    let divider_thick = 8.0;
755
756    let start_drag = {
757        let split_drag = split_drag.clone();
758        move |_pe: PointerEvent| {
759            *split_drag.borrow_mut() = Some(SplitDrag { node_id, dir });
760        }
761    };
762
763    let move_drag = {
764        let split_drag = split_drag.clone();
765        let rect_rc = rect_rc.clone();
766        let state = state.clone();
767        move |pe: PointerEvent| {
768            let Some(sd) = split_drag.borrow().clone() else {
769                return;
770            };
771            if sd.node_id != node_id {
772                return;
773            }
774            let r = *rect_rc.borrow();
775            if r.w <= 1.0 || r.h <= 1.0 {
776                return;
777            }
778            let t = match dir {
779                SplitDir::Horizontal => (pe.position.x - r.x) / r.w,
780                SplitDir::Vertical => (pe.position.y - r.y) / r.h,
781            };
782            state.borrow_mut().set_split_ratio(node_id, t);
783            request_frame();
784        }
785    };
786
787    let end_drag = {
788        let split_drag = split_drag.clone();
789        move |_pe: PointerEvent| {
790            // end any split drag
791            *split_drag.borrow_mut() = None;
792        }
793    };
794
795    // Visible splitter: thin line + thicker hit target (egui-ish)
796    let hovered = split_hover.get() == Some(node_id);
797    let line_color = if hovered { th.focus } else { th.outline };
798    let hit_color = if hovered {
799        line_color.with_alpha(40)
800    } else {
801        line_color.with_alpha(20)
802    };
803
804    let splitter_mod = match dir {
805        SplitDir::Horizontal => Modifier::new().width(divider_thick).fill_max_height(),
806        SplitDir::Vertical => Modifier::new().height(divider_thick).fill_max_width(),
807    };
808
809    let divider = Stack(
810        splitter_mod
811            .background(hit_color)
812            .on_pointer_enter({
813                let split_hover = split_hover.clone();
814                move |_| split_hover.set(Some(node_id))
815            })
816            .on_pointer_leave({
817                let split_hover = split_hover.clone();
818                move |_| {
819                    if split_hover.get() == Some(node_id) {
820                        split_hover.set(None);
821                    }
822                }
823            })
824            .on_pointer_down(start_drag)
825            .on_pointer_move(move_drag)
826            .on_pointer_up(end_drag)
827            .cursor(match dir {
828                SplitDir::Horizontal => CursorIcon::EwResize,
829                SplitDir::Vertical => CursorIcon::NsResize,
830            })
831            .z_index(1500.0),
832    )
833    .child((
834        // Center line
835        match dir {
836            SplitDir::Horizontal => Box(Modifier::new()
837                .absolute()
838                .offset(
839                    Some((divider_thick - 1.0) * 0.5),
840                    Some(0.0),
841                    None,
842                    Some(0.0),
843                )
844                .width(1.0)
845                .fill_max_height()
846                .background(line_color)),
847            SplitDir::Vertical => Box(Modifier::new()
848                .absolute()
849                .offset(
850                    Some(0.0),
851                    Some((divider_thick - 1.0) * 0.5),
852                    Some(0.0),
853                    None,
854                )
855                .height(1.0)
856                .fill_max_width()
857                .background(line_color)),
858        },
859    ));
860
861    let a_view = render_node(
862        a,
863        registry,
864        state,
865        callbacks,
866        hover_sig,
867        drag_active,
868        split_hover,
869        split_drag,
870        key_prefix,
871    );
872    let b_view = render_node(
873        b,
874        registry,
875        state,
876        callbacks,
877        hover_sig,
878        drag_active,
879        split_hover,
880        split_drag,
881        key_prefix,
882    );
883
884    match dir {
885        SplitDir::Horizontal => Row(track.fill_max_size().key(node_id)).child((
886            Box(Modifier::new().weight(ratio)).child(a_view),
887            divider,
888            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
889        )),
890        SplitDir::Vertical => Column(track.fill_max_size().key(node_id)).child((
891            Box(Modifier::new().weight(ratio)).child(a_view),
892            divider,
893            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
894        )),
895    }
896}
897
898fn find_node_mut<'a>(node: &'a mut DockNode, id: u64) -> Option<&'a mut DockNode> {
899    if node.id == id {
900        return Some(node);
901    }
902    match &mut node.kind {
903        DockKind::Split { a, b, .. } => find_node_mut(a, id).or_else(|| find_node_mut(b, id)),
904        _ => None,
905    }
906}
907
908fn remove_panel_in_node(node: &mut DockNode, pid: PanelId) -> bool {
909    match &mut node.kind {
910        DockKind::Empty => false,
911
912        DockKind::Tabs { tabs, active } => {
913            let before = tabs.len();
914            tabs.retain(|&x| x != pid);
915            if tabs.len() != before {
916                if active == &Some(pid) {
917                    *active = tabs.first().copied();
918                }
919                if tabs.is_empty() {
920                    node.kind = DockKind::Empty;
921                }
922                true
923            } else {
924                false
925            }
926        }
927
928        DockKind::Split { a, b, .. } => {
929            let ra = remove_panel_in_node(a, pid);
930            let rb = remove_panel_in_node(b, pid);
931            ra || rb
932        }
933    }
934}
935
936fn normalize_node(node: &mut DockNode) {
937    match &mut node.kind {
938        DockKind::Empty => {}
939        DockKind::Tabs { tabs, active } => {
940            if tabs.is_empty() {
941                node.kind = DockKind::Empty;
942            } else {
943                if active.is_none() || !tabs.contains(&active.unwrap()) {
944                    *active = tabs.first().copied();
945                }
946            }
947        }
948        DockKind::Split { a, b, ratio, .. } => {
949            *ratio = ratio.clamp(0.05, 0.95);
950            normalize_node(a);
951            normalize_node(b);
952
953            let a_empty = matches!(a.kind, DockKind::Empty);
954            let b_empty = matches!(b.kind, DockKind::Empty);
955
956            // Collapse empties
957            if a_empty && !b_empty {
958                node.kind = std::mem::replace(&mut b.kind, DockKind::Empty);
959            } else if b_empty && !a_empty {
960                node.kind = std::mem::replace(&mut a.kind, DockKind::Empty);
961            } else if a_empty && b_empty {
962                node.kind = DockKind::Empty;
963            }
964        }
965    }
966}
967
968fn hash_zone_key(node_id: u64, zone: DropZone) -> u64 {
969    let z = match zone {
970        DropZone::Center => 1u64,
971        DropZone::Left => 2,
972        DropZone::Right => 3,
973        DropZone::Top => 4,
974        DropZone::Bottom => 5,
975        DropZone::Float => 6,
976    };
977    node_id ^ (z.wrapping_mul(0x9E3779B97F4A7C15))
978}
979
980fn hash_str_key(prefix: &str, node_id: u64) -> u64 {
981    let mut h = 1469598103934665603u64;
982    for b in prefix.as_bytes() {
983        h ^= *b as u64;
984        h = h.wrapping_mul(1099511628211u64);
985    }
986    h ^ node_id.wrapping_mul(0x9E3779B97F4A7C15)
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    #[test]
994    fn move_tab_into_center() {
995        let mut st = DockState::new_with_tabs(vec![1, 2, 3]);
996        // Create a second tabs node by splitting
997        assert!(st.dock_panel(1, DropZone::Right, 3));
998        // Now root is split; find some tabs node to dock into
999        // We dock panel 2 into root center should fail because root isn't tabs
1000        assert!(!st.dock_panel(1, DropZone::Center, 2));
1001    }
1002
1003    #[test]
1004    fn remove_collapses_empty_split() {
1005        let mut st = DockState::new_with_tabs(vec![10]);
1006        assert!(st.dock_panel(1, DropZone::Right, 20)); // split created
1007        assert!(st.remove_panel(10));
1008        st.normalize();
1009        // should still not be empty (20 remains)
1010        // root may collapse; ensure at least one tab exists somewhere
1011        fn count_tabs(n: &DockNode) -> usize {
1012            match &n.kind {
1013                DockKind::Tabs { tabs, .. } => tabs.len(),
1014                DockKind::Split { a, b, .. } => count_tabs(a) + count_tabs(b),
1015                DockKind::Empty => 0,
1016            }
1017        }
1018        assert_eq!(count_tabs(&st.root), 1);
1019    }
1020}