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::TextStyle;
10use repose_ui::*;
11
12pub type PanelId = u64;
13
14#[derive(Clone)]
15pub struct DockPanel {
16    pub id: PanelId,
17    pub title: String,
18    pub content: Rc<dyn Fn() -> View>,
19}
20
21#[derive(Clone, Default)]
22pub struct DockCallbacks {
23    /// Optional popout handler. If provided, the docking system will call it
24    /// when a panel is dropped on the "float" target or when user taps popout.
25    pub on_popout: Option<Rc<dyn Fn(PanelId)>>,
26
27    /// Optional close handler (tab close button).
28    pub on_close: Option<Rc<dyn Fn(PanelId)>>,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum SplitDir {
33    Horizontal, // left/right
34    Vertical,   // top/bottom
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum DropZone {
39    Center,
40    Left,
41    Right,
42    Top,
43    Bottom,
44    Float,
45}
46
47/// Persistent docking state.
48/// Store this in `remember_state_with_key(...)` or `SavedState` etc.
49#[derive(Clone)]
50pub struct DockState {
51    pub root: DockNode,
52    next_id: u64,
53}
54
55#[derive(Clone)]
56pub struct DockNode {
57    pub id: u64,
58    pub kind: DockKind,
59}
60
61#[derive(Clone)]
62pub enum DockKind {
63    Empty,
64    Tabs {
65        tabs: Vec<PanelId>,
66        active: Option<PanelId>,
67    },
68    Split {
69        dir: SplitDir,
70        ratio: f32, // 0..1
71        a: Box<DockNode>,
72        b: Box<DockNode>,
73    },
74}
75
76impl DockState {
77    pub fn new_with_tabs(tabs: Vec<PanelId>) -> Self {
78        let mut st = Self {
79            root: DockNode {
80                id: 1,
81                kind: DockKind::Empty,
82            },
83            next_id: 2,
84        };
85        st.root.kind = DockKind::Tabs { tabs, active: None };
86        st.normalize();
87        st
88    }
89
90    /// Create a DockState from a pre-built root node.
91    /// The `max_node_id` should be higher than any node ID used in the tree.
92    pub fn from_root(root: DockNode, max_node_id: u64) -> Self {
93        let mut st = Self {
94            root,
95            next_id: max_node_id + 1,
96        };
97        st.normalize();
98        st
99    }
100
101    fn alloc_id(&mut self) -> u64 {
102        let id = self.next_id;
103        self.next_id += 1;
104        id
105    }
106
107    pub fn normalize(&mut self) {
108        normalize_node(&mut self.root);
109    }
110
111    /// Remove panel without normalizing - for use in compound operations
112    pub fn remove_panel_no_normalize(&mut self, pid: PanelId) -> bool {
113        remove_panel_in_node(&mut self.root, pid)
114    }
115
116    pub fn remove_panel(&mut self, pid: PanelId) -> bool {
117        let removed = remove_panel_in_node(&mut self.root, pid);
118        if removed {
119            normalize_node(&mut self.root);
120        }
121        removed
122    }
123
124    pub fn set_active(&mut self, tabs_node_id: u64, pid: PanelId) {
125        if let Some(n) = find_node_mut(&mut self.root, tabs_node_id)
126            && let DockKind::Tabs { tabs, active } = &mut n.kind
127            && tabs.contains(&pid)
128        {
129            *active = Some(pid);
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            && let DockKind::Split { ratio: r, .. } = &mut n.kind
137        {
138            *r = ratio;
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 => Box(Modifier::new()
346            .fill_max_size()
347            .background(theme().surface)
348            .key(node.id))
349        .child(Box(Modifier::new().fill_max_size()).child(Text("Empty").color(theme().on_surface))),
350
351        DockKind::Tabs { tabs, active } => render_tabs(
352            node.id,
353            tabs,
354            *active,
355            registry,
356            state,
357            callbacks,
358            hover_sig,
359            drag_active,
360            split_hover,
361            key_prefix,
362        ),
363
364        DockKind::Split { dir, ratio, a, b } => render_split(
365            node.id,
366            *dir,
367            *ratio,
368            a,
369            b,
370            registry,
371            state,
372            callbacks,
373            hover_sig,
374            drag_active,
375            split_hover,
376            split_drag,
377            key_prefix,
378        ),
379    }
380}
381
382fn render_tabs(
383    node_id: u64,
384    tabs: &Vec<PanelId>,
385    active: Option<PanelId>,
386    registry: &Rc<HashMap<PanelId, DockPanel>>,
387    state: &Rc<RefCell<DockState>>,
388    callbacks: &DockCallbacks,
389    hover_sig: &Signal<Option<HoverHint>>,
390    drag_active: &Signal<bool>,
391    _split_hover: &Signal<Option<u64>>,
392    key_prefix: &str,
393) -> View {
394    let th = theme();
395
396    // Ensure active is valid
397    let active_pid = active.or_else(|| tabs.first().copied());
398
399    let tabbar_rect = remember_with_key(format!("dock:tabbar_rect:{key_prefix}:{node_id}"), || {
400        RefCell::new(Rect::default())
401    });
402
403    let mut bar_mod = Modifier::new()
404        .fill_max_width()
405        .height(40.0)
406        .background(th.surface)
407        .border(1.0, th.outline, 0.0)
408        .padding(6.0)
409        .painter({
410            let tabbar_rect = tabbar_rect.clone();
411            move |_scene, r, _alpha| *tabbar_rect.borrow_mut() = r
412        });
413
414    if drag_active.get() {
415        bar_mod = bar_mod.on_drop({
416            let state = state.clone();
417            let tabbar_rect = tabbar_rect.clone();
418            let hover_sig = hover_sig.clone();
419            let drag_active = drag_active.clone();
420
421            move |ev| {
422                let Some(p) = ev.payload.as_ref().downcast_ref::<DockTabPayload>() else {
423                    return false;
424                };
425
426                let mut st = state.borrow_mut();
427
428                // Always rm WITHOUT normalizing to preserve node_id validity
429                st.remove_panel_no_normalize(p.panel_id);
430
431                let r = *tabbar_rect.borrow();
432                let t = if r.w > 1.0 {
433                    ((ev.position.x - r.x) / r.w).clamp(0.0, 1.0)
434                } else {
435                    1.0
436                };
437
438                if let Some(n) = find_node_mut(&mut st.root, node_id) {
439                    if matches!(n.kind, DockKind::Empty) {
440                        n.kind = DockKind::Tabs {
441                            tabs: Vec::new(),
442                            active: None,
443                        };
444                    }
445
446                    if let DockKind::Tabs { tabs, active } = &mut n.kind {
447                        tabs.retain(|&x| x != p.panel_id);
448                        let idx =
449                            ((t * (tabs.len() as f32 + 1.0)).floor() as usize).min(tabs.len());
450                        tabs.insert(idx, p.panel_id);
451                        *active = Some(p.panel_id);
452                    }
453                }
454
455                st.normalize();
456
457                hover_sig.set(None);
458                drag_active.set(false);
459                request_frame();
460                true
461            }
462        });
463    }
464
465    let tab_bar = Row(bar_mod).with_children(
466        tabs.iter()
467            .copied()
468            .filter_map(|pid| {
469                let panel = registry.get(&pid)?;
470                let is_active = Some(pid) == active_pid;
471
472                let state_set = state.clone();
473                let title = panel.title.clone();
474
475                let drag_pid = pid;
476
477                let cb_close = callbacks.on_close.clone();
478                let cb_pop = callbacks.on_popout.clone();
479
480                Some(
481                    Stack(
482                        Modifier::new()
483                            .key(pid)
484                            .height(32.0)
485                            .padding(4.0)
486                            .clip_rounded(th.shapes.small)
487                            .background(if is_active {
488                                th.primary.with_alpha(80)
489                            } else {
490                                th.surface
491                            })
492                            .border(1.0, th.outline, th.shapes.small),
493                    )
494                    .child((
495                        Box(Modifier::new()
496                            .height(24.0)
497                            .offset(None, Some(4.0), None, None)
498                            .clickable()
499                            .cursor(CursorIcon::Grab)
500                            .on_pointer_down({
501                                let state_set = state_set.clone();
502                                move |_| {
503                                    state_set.borrow_mut().set_active(node_id, pid);
504                                    request_frame();
505                                }
506                            })
507                            .on_drag_start({
508                                let drag_active = drag_active.clone();
509                                move |_start| {
510                                    drag_active.set(true);
511                                    Some(Rc::new(DockTabPayload { panel_id: drag_pid })
512                                        as Rc<dyn Any>)
513                                }
514                            })
515                            .on_drag_end({
516                                let hover_sig = hover_sig.clone();
517                                let drag_active = drag_active.clone();
518                                move |_end| {
519                                    drag_active.set(false);
520                                    hover_sig.set(None);
521                                }
522                            }))
523                        .child(
524                            Row(Modifier::new().height(24.0))
525                                .child((Text(title).color(th.on_surface),)),
526                        ),
527                        Row(Modifier::new().absolute().height(24.0).offset(
528                            None,
529                            Some(4.0),
530                            Some(2.0),
531                            None,
532                        ))
533                        .child((
534                            if let Some(pop) = cb_pop {
535                                Box(Modifier::new()
536                                    .clickable()
537                                    .on_pointer_down(move |_| pop(pid))
538                                    .padding(2.0))
539                                .child(Text("↗").size(12.0))
540                            } else {
541                                Box(Modifier::new())
542                            },
543                            if let Some(close) = cb_close {
544                                Box(Modifier::new()
545                                    .clickable()
546                                    .on_pointer_down(move |_| close(pid))
547                                    .padding(2.0))
548                                .child(Text("×").size(12.0))
549                            } else {
550                                Box(Modifier::new())
551                            },
552                        )),
553                    )),
554                )
555            })
556            .collect::<Vec<_>>(),
557    );
558
559    // Content
560    let content = if let Some(pid) = active_pid {
561        if let Some(panel) = registry.get(&pid) {
562            (panel.content)()
563        } else {
564            Text("Missing panel").color(th.error)
565        }
566    } else {
567        Text("No tabs").color(th.on_surface)
568    };
569
570    // Drop zones overlay (always present; highlight only when hovered)
571    let overlay = dock_drop_overlay(node_id, state, hover_sig, drag_active, key_prefix);
572
573    let tab_h = 40.0;
574
575    Stack(Modifier::new().fill_max_size().key(node_id)).child((
576        Column(Modifier::new().fill_max_size()).child((
577            tab_bar,
578            Box(Modifier::new().fill_max_size().background(th.background))
579                .child(Box(Modifier::new().fill_max_size().padding(8.0)).child(content)),
580        )),
581        Box(Modifier::new()
582            .absolute()
583            .offset(Some(0.0), Some(tab_h), Some(0.0), Some(0.0)))
584        .child(overlay),
585    ))
586}
587
588fn dock_drop_overlay(
589    node_id: u64,
590    state: &Rc<RefCell<DockState>>,
591    hover_sig: &Signal<Option<HoverHint>>,
592    drag_active: &Signal<bool>,
593    key_prefix: &str,
594) -> View {
595    let th = theme();
596    if !drag_active.get() {
597        return Box(Modifier::new());
598    }
599
600    let zone_dp = 48.0;
601
602    let hover = hover_sig.get();
603
604    let mk_zone = |zone: DropZone, m: Modifier| -> View {
605        let state2 = state.clone();
606        let hover2 = hover_sig.clone();
607
608        let label = match zone {
609            // Maybe have icons here later?
610            DropZone::Center => " ",
611            DropZone::Left => " ",
612            DropZone::Right => " ",
613            DropZone::Top => " ",
614            DropZone::Bottom => " ",
615            DropZone::Float => " ",
616        };
617
618        let highlight = if hover.as_ref() == Some(&HoverHint { node_id, zone }) {
619            Stack(
620                Modifier::new()
621                    .fill_max_size()
622                    .background(th.primary.with_alpha(51))
623                    .border(2.0, th.primary, 0.0),
624            )
625            .child(Text(label).size(12.0).color(th.on_primary))
626        } else {
627            Stack(
628                Modifier::new()
629                    .fill_max_size()
630                    .border(1.0, th.outline_variant, 0.0),
631            )
632            .child(Text(label).size(12.0).color(th.on_surface_variant))
633        };
634
635        Stack(
636            m.z_index(2000.0)
637                .key(hash_zone_key(node_id, zone))
638                .on_drag_enter({
639                    let hover2 = hover2.clone();
640                    move |_ev| {
641                        hover2.set(Some(HoverHint { node_id, zone }));
642                    }
643                })
644                .on_drag_over({
645                    let hover2 = hover2.clone();
646                    move |_ev| {
647                        hover2.set(Some(HoverHint { node_id, zone }));
648                    }
649                })
650                .on_drag_leave({
651                    let hover2 = hover2.clone();
652                    move |_ev| {
653                        // Only clear if we were hovering this
654                        if hover2.get().as_ref() == Some(&HoverHint { node_id, zone }) {
655                            hover2.set(None);
656                        }
657                    }
658                })
659                .on_drop(move |ev| {
660                    let Some(p) = ev.payload.as_ref().downcast_ref::<DockTabPayload>() else {
661                        return false;
662                    };
663
664                    let ok = state2.borrow_mut().dock_panel(node_id, zone, p.panel_id);
665                    hover2.set(None);
666                    request_frame();
667                    ok
668                }),
669        )
670        .child(highlight)
671    };
672
673    // Layout zones using absolute rects (no need for measured size):
674    // left/right/top/bottom thickness = zone_dp; center = remainder.
675    let left = mk_zone(
676        DropZone::Left,
677        Modifier::new()
678            .absolute()
679            .offset(Some(0.0), Some(0.0), None, Some(0.0))
680            .width(zone_dp),
681    );
682
683    let right = mk_zone(
684        DropZone::Right,
685        Modifier::new()
686            .absolute()
687            .offset(None, Some(0.0), Some(0.0), Some(0.0))
688            .width(zone_dp),
689    );
690
691    let top = mk_zone(
692        DropZone::Top,
693        Modifier::new()
694            .absolute()
695            .offset(Some(zone_dp), Some(0.0), Some(zone_dp), None)
696            .height(zone_dp),
697    );
698
699    let bottom = mk_zone(
700        DropZone::Bottom,
701        Modifier::new()
702            .absolute()
703            .offset(Some(zone_dp), None, Some(zone_dp), Some(0.0))
704            .height(zone_dp),
705    );
706
707    let center = mk_zone(
708        DropZone::Center,
709        Modifier::new().absolute().offset(
710            Some(zone_dp),
711            Some(zone_dp),
712            Some(zone_dp),
713            Some(zone_dp),
714        ),
715    );
716
717    Stack(
718        Modifier::new()
719            .fill_max_size()
720            .key(hash_str_key(key_prefix, node_id)),
721    )
722    .child((left, right, top, bottom, center))
723}
724
725fn render_split(
726    node_id: u64,
727    dir: SplitDir,
728    ratio: f32,
729    a: &DockNode,
730    b: &DockNode,
731    registry: &Rc<HashMap<PanelId, DockPanel>>,
732    state: &Rc<RefCell<DockState>>,
733    callbacks: &DockCallbacks,
734    hover_sig: &Signal<Option<HoverHint>>,
735    drag_active: &Signal<bool>,
736    split_hover: &Signal<Option<u64>>,
737    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
738    key_prefix: &str,
739) -> View {
740    let th = theme();
741    let ratio = ratio.clamp(0.05, 0.95);
742
743    // Track this split container rect so the divider can compute ratio from pointer position.
744    let rect_rc = remember_with_key(format!("dock:split_rect:{}:{node_id}", key_prefix), || {
745        RefCell::new(Rect::default())
746    });
747
748    // Paint-only hook to store rect
749    let track = {
750        let rect_rc = rect_rc.clone();
751        Modifier::new().painter(move |_scene, r, _alpha| {
752            *rect_rc.borrow_mut() = r;
753        })
754    };
755
756    let divider_thick = 8.0;
757
758    let start_drag = {
759        let split_drag = split_drag.clone();
760        move |_pe: PointerEvent| {
761            *split_drag.borrow_mut() = Some(SplitDrag { node_id, dir });
762        }
763    };
764
765    let move_drag = {
766        let split_drag = split_drag.clone();
767        let rect_rc = rect_rc.clone();
768        let state = state.clone();
769        move |pe: PointerEvent| {
770            let Some(sd) = split_drag.borrow().clone() else {
771                return;
772            };
773            if sd.node_id != node_id {
774                return;
775            }
776            let r = *rect_rc.borrow();
777            if r.w <= 1.0 || r.h <= 1.0 {
778                return;
779            }
780            let t = match dir {
781                SplitDir::Horizontal => (pe.position.x - r.x) / r.w,
782                SplitDir::Vertical => (pe.position.y - r.y) / r.h,
783            };
784            state.borrow_mut().set_split_ratio(node_id, t);
785            request_frame();
786        }
787    };
788
789    let end_drag = {
790        let split_drag = split_drag.clone();
791        move |_pe: PointerEvent| {
792            // end any split drag
793            *split_drag.borrow_mut() = None;
794        }
795    };
796
797    // Visible splitter: thin line + thicker hit target (egui-ish)
798    let hovered = split_hover.get() == Some(node_id);
799    let line_color = if hovered { th.focus } else { th.outline };
800    let hit_color = if hovered {
801        line_color.with_alpha(40)
802    } else {
803        line_color.with_alpha(20)
804    };
805
806    let splitter_mod = match dir {
807        SplitDir::Horizontal => Modifier::new().width(divider_thick).fill_max_height(),
808        SplitDir::Vertical => Modifier::new().height(divider_thick).fill_max_width(),
809    };
810
811    let divider = Stack(
812        splitter_mod
813            .background(hit_color)
814            .on_pointer_enter({
815                let split_hover = split_hover.clone();
816                move |_| split_hover.set(Some(node_id))
817            })
818            .on_pointer_leave({
819                let split_hover = split_hover.clone();
820                move |_| {
821                    if split_hover.get() == Some(node_id) {
822                        split_hover.set(None);
823                    }
824                }
825            })
826            .on_pointer_down(start_drag)
827            .on_pointer_move(move_drag)
828            .on_pointer_up(end_drag)
829            .cursor(match dir {
830                SplitDir::Horizontal => CursorIcon::EwResize,
831                SplitDir::Vertical => CursorIcon::NsResize,
832            })
833            .z_index(1500.0),
834    )
835    .child((
836        // Center line
837        match dir {
838            SplitDir::Horizontal => Box(Modifier::new()
839                .absolute()
840                .offset(
841                    Some((divider_thick - 1.0) * 0.5),
842                    Some(0.0),
843                    None,
844                    Some(0.0),
845                )
846                .width(1.0)
847                .fill_max_height()
848                .background(line_color)),
849            SplitDir::Vertical => Box(Modifier::new()
850                .absolute()
851                .offset(
852                    Some(0.0),
853                    Some((divider_thick - 1.0) * 0.5),
854                    Some(0.0),
855                    None,
856                )
857                .height(1.0)
858                .fill_max_width()
859                .background(line_color)),
860        },
861    ));
862
863    let a_view = render_node(
864        a,
865        registry,
866        state,
867        callbacks,
868        hover_sig,
869        drag_active,
870        split_hover,
871        split_drag,
872        key_prefix,
873    );
874    let b_view = render_node(
875        b,
876        registry,
877        state,
878        callbacks,
879        hover_sig,
880        drag_active,
881        split_hover,
882        split_drag,
883        key_prefix,
884    );
885
886    match dir {
887        SplitDir::Horizontal => Row(track.fill_max_size().key(node_id)).child((
888            Box(Modifier::new().weight(ratio)).child(a_view),
889            divider,
890            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
891        )),
892        SplitDir::Vertical => Column(track.fill_max_size().key(node_id)).child((
893            Box(Modifier::new().weight(ratio)).child(a_view),
894            divider,
895            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
896        )),
897    }
898}
899
900fn find_node_mut(node: &mut DockNode, id: u64) -> Option<&mut DockNode> {
901    if node.id == id {
902        return Some(node);
903    }
904    match &mut node.kind {
905        DockKind::Split { a, b, .. } => find_node_mut(a, id).or_else(|| find_node_mut(b, id)),
906        _ => None,
907    }
908}
909
910fn remove_panel_in_node(node: &mut DockNode, pid: PanelId) -> bool {
911    match &mut node.kind {
912        DockKind::Empty => false,
913
914        DockKind::Tabs { tabs, active } => {
915            let before = tabs.len();
916            tabs.retain(|&x| x != pid);
917            if tabs.len() != before {
918                if active == &Some(pid) {
919                    *active = tabs.first().copied();
920                }
921                if tabs.is_empty() {
922                    node.kind = DockKind::Empty;
923                }
924                true
925            } else {
926                false
927            }
928        }
929
930        DockKind::Split { a, b, .. } => {
931            let ra = remove_panel_in_node(a, pid);
932            let rb = remove_panel_in_node(b, pid);
933            ra || rb
934        }
935    }
936}
937
938fn normalize_node(node: &mut DockNode) {
939    match &mut node.kind {
940        DockKind::Empty => {}
941        DockKind::Tabs { tabs, active } => {
942            if tabs.is_empty() {
943                node.kind = DockKind::Empty;
944            } else if active.is_none() || !tabs.contains(&active.unwrap()) {
945                *active = tabs.first().copied();
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        // Root is now a Split node; docking center into a Split should fail
999        assert!(!st.dock_panel(st.root.id, DropZone::Center, 2));
1000    }
1001
1002    #[test]
1003    fn remove_collapses_empty_split() {
1004        let mut st = DockState::new_with_tabs(vec![10]);
1005        assert!(st.dock_panel(1, DropZone::Right, 20)); // split created
1006        assert!(st.remove_panel(10));
1007        st.normalize();
1008        // should still not be empty (20 remains)
1009        // root may collapse; ensure at least one tab exists somewhere
1010        fn count_tabs(n: &DockNode) -> usize {
1011            match &n.kind {
1012                DockKind::Tabs { tabs, .. } => tabs.len(),
1013                DockKind::Split { a, b, .. } => count_tabs(a) + count_tabs(b),
1014                DockKind::Empty => 0,
1015            }
1016        }
1017        assert_eq!(count_tabs(&st.root), 1);
1018    }
1019}