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