Skip to main content

repose_docking/
lib.rs

1#![allow(non_snake_case)]
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::rc::Rc;
6
7use repose_core::*;
8use repose_ui::TextStyle;
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
250/// Ephemeral, reusable dock behavior handle. Created by [`remember_dock_handle`]
251/// and passed to the [`DockModifierExt`] helpers so custom chrome can reuse the
252/// exact same docking behavior.
253#[derive(Clone)]
254pub struct DockHandle {
255    pub(crate) key: String,
256    pub(crate) state: Rc<RefCell<DockState>>,
257    pub(crate) callbacks: DockCallbacks,
258    pub(crate) hover_sig: Signal<Option<HoverHint>>,
259    pub(crate) tab_hover: Signal<Option<PanelId>>,
260    pub(crate) drag_active: Signal<bool>,
261}
262
263/// Create a [`DockHandle`] bound to a dock state and callbacks. Remember this in
264/// your widget so it survives recompositions.
265pub fn remember_dock_handle(
266    key: impl Into<String>,
267    state: Rc<RefCell<DockState>>,
268    callbacks: DockCallbacks,
269) -> DockHandle {
270    let key = key.into();
271
272    let hover_sig = remember_with_key(format!("dock:hover:{key}"), || signal(None::<HoverHint>));
273    let tab_hover = remember_with_key(format!("dock:tab_hover:{key}"), || signal(None::<PanelId>));
274    let drag_active = remember_with_key(format!("dock:drag_active:{key}"), || signal(false));
275
276    DockHandle {
277        key,
278        state,
279        callbacks,
280        hover_sig: (*hover_sig).clone(),
281        tab_hover: (*tab_hover).clone(),
282        drag_active: (*drag_active).clone(),
283    }
284}
285
286/// Modular dock behavior modifiers. Lets custom chrome reuse docking behavior:
287///
288/// ```ignore
289/// use repose_docking::{DockModifierExt, DockHandle};
290/// Row(Modifier::new().dock_tab_source(&dock, panel_id)).child(...)
291/// ```
292pub trait DockModifierExt: Sized {
293    /// Make this node a draggable dock-tab source.
294    fn dock_tab_source(self, dock: &DockHandle, panel_id: PanelId) -> Modifier;
295
296    /// Make a tab strip accept dropped tabs for reorder/insert.
297    fn dock_tab_strip_drop_target(
298        self,
299        dock: &DockHandle,
300        node_id: u64,
301        tabbar_rect: Rc<RefCell<Rect>>,
302    ) -> Modifier;
303
304    /// Make this node one specific dock drop zone.
305    fn dock_drop_zone(self, dock: &DockHandle, node_id: u64, zone: DropZone) -> Modifier;
306
307    /// Make this node the outer "float/popout" target.
308    fn dock_float_target(self, dock: &DockHandle) -> Modifier;
309}
310
311impl DockModifierExt for Modifier {
312    fn dock_tab_source(self, dock: &DockHandle, panel_id: PanelId) -> Modifier {
313        let drag_active_start = dock.drag_active.clone();
314
315        let hover_end = dock.hover_sig.clone();
316        let drag_active_end = dock.drag_active.clone();
317
318        self.cursor(CursorIcon::Grab)
319            .drag_source::<DockTabPayload>(move |_start| {
320                drag_active_start.set(true);
321                Some(DockTabPayload { panel_id })
322            })
323            .on_drag_end(move |_end| {
324                drag_active_end.set(false);
325                hover_end.set(None);
326            })
327    }
328
329    fn dock_tab_strip_drop_target(
330        self,
331        dock: &DockHandle,
332        node_id: u64,
333        tabbar_rect: Rc<RefCell<Rect>>,
334    ) -> Modifier {
335        let state = dock.state.clone();
336        let hover_sig = dock.hover_sig.clone();
337        let drag_active = dock.drag_active.clone();
338
339        self.on_drop_typed::<DockTabPayload>(move |ev, p| {
340            let mut st = state.borrow_mut();
341
342            // Preserve current logic: remove without normalizing so node_id remains valid.
343            st.remove_panel_no_normalize(p.panel_id);
344
345            let r = *tabbar_rect.borrow();
346            let t = if r.w > 1.0 {
347                ((ev.position.x - r.x) / r.w).clamp(0.0, 1.0)
348            } else {
349                1.0
350            };
351
352            if let Some(n) = find_node_mut(&mut st.root, node_id) {
353                if matches!(n.kind, DockKind::Empty) {
354                    n.kind = DockKind::Tabs {
355                        tabs: Vec::new(),
356                        active: None,
357                    };
358                }
359
360                if let DockKind::Tabs { tabs, active } = &mut n.kind {
361                    tabs.retain(|&x| x != p.panel_id);
362                    let idx = ((t * (tabs.len() as f32 + 1.0)).floor() as usize).min(tabs.len());
363                    tabs.insert(idx, p.panel_id);
364                    *active = Some(p.panel_id);
365                }
366            }
367
368            st.normalize();
369            hover_sig.set(None);
370            drag_active.set(false);
371            request_frame();
372            true
373        })
374    }
375
376    fn dock_drop_zone(self, dock: &DockHandle, node_id: u64, zone: DropZone) -> Modifier {
377        let hover_enter = dock.hover_sig.clone();
378        let hover_over = dock.hover_sig.clone();
379        let hover_leave = dock.hover_sig.clone();
380        let hover_drop = dock.hover_sig.clone();
381        let state = dock.state.clone();
382
383        self.z_index(3000.0)
384            .render_z_index(3000.0)
385            .key(hash_zone_key(node_id, zone))
386            .on_drag_enter_typed::<DockTabPayload>(move |_ev, _p| {
387                hover_enter.set(Some(HoverHint { node_id, zone }));
388            })
389            .on_drag_over_typed::<DockTabPayload>(move |_ev, _p| {
390                hover_over.set(Some(HoverHint { node_id, zone }));
391            })
392            .on_drag_leave_typed::<DockTabPayload>(move |_ev, _p| {
393                if hover_leave.get().as_ref() == Some(&HoverHint { node_id, zone }) {
394                    hover_leave.set(None);
395                }
396            })
397            .on_drop_typed::<DockTabPayload>(move |_ev, p| {
398                let ok = state.borrow_mut().dock_panel(node_id, zone, p.panel_id);
399                hover_drop.set(None);
400                request_frame();
401                ok
402            })
403    }
404
405    fn dock_float_target(self, dock: &DockHandle) -> Modifier {
406        let state = dock.state.clone();
407        let hover_sig = dock.hover_sig.clone();
408        let cb_pop = dock.callbacks.on_popout.clone();
409
410        self.on_drop_typed::<DockTabPayload>(move |_ev, p| {
411            let Some(pop) = cb_pop.as_ref() else {
412                return false;
413            };
414
415            state.borrow_mut().remove_panel(p.panel_id);
416            pop(p.panel_id);
417            hover_sig.set(None);
418            request_frame();
419            true
420        })
421    }
422}
423
424pub fn DockArea(
425    key: impl Into<String>,
426    modifier: Modifier,
427    state: Rc<RefCell<DockState>>,
428    panels: Vec<DockPanel>,
429    callbacks: DockCallbacks,
430) -> View {
431    let key = key.into();
432    let registry = Rc::new(build_registry(panels));
433
434    let dock = remember_dock_handle(key.clone(), state, callbacks);
435
436    let split_hover = remember_with_key(format!("dock:split_hover:{key}"), || signal(None::<u64>));
437    let split_drag = remember_with_key(format!("dock:split_drag:{key}"), || {
438        RefCell::new(None::<SplitDrag>)
439    });
440
441    // Outer "float" drop target: if you drop a tab anywhere not handled by inner targets.
442    // We set z-index low so inner targets win.
443    let float_target = Box(
444        Modifier::new()
445            .fill_max_size()
446            .z_index(-1000.0)
447            .dock_float_target(&dock),
448    );
449
450    // Actual docking UI
451    let root_view = {
452        let st = dock.state.borrow().clone();
453        render_node(
454            &st.root,
455            &registry,
456            &dock,
457            &split_hover,
458            &split_drag,
459            key.as_str(),
460        )
461    };
462
463    ZStack(modifier.fill_max_size()).child((
464        Box(Modifier::new()
465            .absolute()
466            .offset(Some(0.0), Some(0.0), Some(0.0), Some(0.0)))
467        .child(float_target),
468        Box(Modifier::new()
469            .absolute()
470            .offset(Some(0.0), Some(0.0), Some(0.0), Some(0.0)))
471        .child(root_view),
472    ))
473}
474
475fn build_registry(panels: Vec<DockPanel>) -> HashMap<PanelId, DockPanel> {
476    let mut m = HashMap::new();
477    for p in panels {
478        m.insert(p.id, p);
479    }
480    m
481}
482
483fn render_node(
484    node: &DockNode,
485    registry: &Rc<HashMap<PanelId, DockPanel>>,
486    dock: &DockHandle,
487    split_hover: &Signal<Option<u64>>,
488    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
489    key_prefix: &str,
490) -> View {
491    match &node.kind {
492        DockKind::Empty => Box(
493            Modifier::new()
494                .fill_max_size()
495                .padding(6.0)
496                .background(theme().surface_container_lowest)
497                .clip_rounded(theme().shapes.medium)
498                .border(
499                    1.0,
500                    theme().outline_variant.with_alpha(80),
501                    theme().shapes.medium,
502                )
503                .key(node.id),
504        )
505        .child(
506            Box(Modifier::new().fill_max_size().padding(16.0)).child(
507                Text("Drop panel here")
508                    .size(theme().typography.label_medium)
509                    .color(theme().on_surface_variant),
510            ),
511        ),
512
513        DockKind::Tabs { tabs, active } => render_tabs(
514            node.id,
515            tabs,
516            *active,
517            registry,
518            dock,
519            split_hover,
520            key_prefix,
521        ),
522
523        DockKind::Split { dir, ratio, a, b } => render_split(
524            node.id,
525            *dir,
526            *ratio,
527            a,
528            b,
529            registry,
530            dock,
531            split_hover,
532            split_drag,
533            key_prefix,
534        ),
535    }
536}
537
538fn render_tabs(
539    node_id: u64,
540    tabs: &Vec<PanelId>,
541    active: Option<PanelId>,
542    registry: &Rc<HashMap<PanelId, DockPanel>>,
543    dock: &DockHandle,
544    _split_hover: &Signal<Option<u64>>,
545    key_prefix: &str,
546) -> View {
547    let th = theme();
548
549    const PANEL_PAD: f32 = 5.0;
550    const TAB_BAR_H: f32 = 44.0;
551    const TAB_H: f32 = 32.0;
552    const TAB_RADIUS: f32 = 16.0;
553
554    // Ensure active is valid
555    let active_pid = active.or_else(|| tabs.first().copied());
556
557    let tabbar_rect = remember_with_key(format!("dock:tabbar_rect:{key_prefix}:{node_id}"), || {
558        RefCell::new(Rect::default())
559    });
560
561    let strip_bg = th.surface_container_low;
562    let active_bg = th.secondary_container;
563    let active_fg = th.on_secondary_container;
564    let inactive_fg = th.on_surface_variant;
565    let hover_bg = th.surface_container_high;
566
567    let mut bar_mod = Modifier::new()
568        .fill_max_width()
569        .height(TAB_BAR_H)
570        .background(strip_bg)
571        .padding_values(PaddingValues {
572            left: 8.0,
573            right: 8.0,
574            top: 6.0,
575            bottom: 6.0,
576        })
577        .gap(6.0)
578        .painter({
579            let tabbar_rect = tabbar_rect.clone();
580            move |_scene, r, _alpha| *tabbar_rect.borrow_mut() = r
581        });
582
583    if dock.drag_active.get() {
584        bar_mod = bar_mod.dock_tab_strip_drop_target(dock, node_id, tabbar_rect.clone());
585    }
586
587    let tab_bar = Row(bar_mod).with_children(
588        tabs.iter()
589            .copied()
590            .filter_map(|pid| {
591                let panel = registry.get(&pid)?;
592                let is_active = Some(pid) == active_pid;
593                let is_hovered = dock.tab_hover.get() == Some(pid);
594
595                let state_set = dock.state.clone();
596                let title = panel.title.clone();
597                let drag_pid = pid;
598
599                let cb_close = dock.callbacks.on_close.clone();
600                let cb_pop = dock.callbacks.on_popout.clone();
601
602                let tab_bg = if is_active {
603                    active_bg
604                } else if is_hovered {
605                    hover_bg
606                } else {
607                    Color::TRANSPARENT
608                };
609
610                let tab_fg = if is_active { active_fg } else { inactive_fg };
611
612                let hover_in = {
613                    let tab_hover = dock.tab_hover.clone();
614                    move |_| tab_hover.set(Some(pid))
615                };
616
617                let hover_out = {
618                    let tab_hover = dock.tab_hover.clone();
619                    move |_| {
620                        if tab_hover.get() == Some(pid) {
621                            tab_hover.set(None);
622                        }
623                    }
624                };
625
626                let pop_view = if let Some(pop) = cb_pop {
627                    let state_for_pop = dock.state.clone();
628                    dock_tab_icon_button("↗", tab_fg, move |_| {
629                        state_for_pop.borrow_mut().remove_panel(pid);
630                        pop(pid);
631                        request_frame();
632                    })
633                } else {
634                    Box(Modifier::new())
635                };
636
637                let close_view = if let Some(close) = cb_close {
638                    dock_tab_icon_button("×", tab_fg, move |_| {
639                        close(pid);
640                        request_frame();
641                    })
642                } else {
643                    Box(Modifier::new())
644                };
645
646                Some(
647                    Row(
648                        Modifier::new()
649                            .key(pid)
650                            .height(TAB_H)
651                            .min_width(108.0)
652                            .max_width(240.0)
653                            .clip_rounded(TAB_RADIUS)
654                            .background(tab_bg)
655                            .padding_values(PaddingValues {
656                                left: 12.0,
657                                right: 4.0,
658                                top: 0.0,
659                                bottom: 0.0,
660                            })
661                            .gap(4.0)
662                            .clickable()
663                            .on_pointer_enter(hover_in)
664                            .on_pointer_leave(hover_out)
665                            .on_pointer_down({
666                                let state_set = state_set.clone();
667                                move |_| {
668                                    state_set.borrow_mut().set_active(node_id, pid);
669                                    request_frame();
670                                }
671                            })
672                            .drag_preview_chip(title.clone(), th.primary)
673                            .dock_tab_source(dock, drag_pid),
674                    )
675                    .child((
676                        Box(
677                            Modifier::new()
678                                .height(TAB_H)
679                                .weight(1.0)
680                                .padding_values(PaddingValues {
681                                    left: 0.0,
682                                    right: 4.0,
683                                    top: 0.0,
684                                    bottom: 0.0,
685                                })
686                                .content_alignment(Alignment::Center),
687                        )
688                        .child(
689                            Text(title)
690                                .size(th.typography.label_large)
691                                .single_line()
692                                .overflow_ellipsize()
693                                .color(tab_fg),
694                        ),
695                        pop_view,
696                        close_view,
697                    )),
698                )
699            })
700            .collect::<Vec<_>>(),
701    );
702
703    // Content
704    let content = if let Some(pid) = active_pid {
705        if let Some(panel) = registry.get(&pid) {
706            (panel.content)()
707        } else {
708            Text("Missing panel").color(th.error)
709        }
710    } else {
711        Text("No tabs").color(th.on_surface_variant)
712    };
713
714    // Drop zones overlay (present only while dragging)
715    let overlay = dock_drop_overlay(node_id, dock, key_prefix);
716
717    ZStack(Modifier::new().fill_max_size().key(node_id)).child((
718        Column(
719            Modifier::new()
720                .fill_max_size()
721                .padding(PANEL_PAD)
722                .clip_rounded(th.shapes.medium)
723                .background(th.surface_container_lowest)
724                .border(1.0, th.outline_variant.with_alpha(70), th.shapes.medium),
725        )
726        .child((
727            tab_bar,
728            Box(
729                Modifier::new()
730                    .fill_max_size()
731                    .background(th.surface_container_lowest),
732            )
733            .child(Box(Modifier::new().fill_max_size().padding(8.0)).child(content)),
734        )),
735        Box(Modifier::new()
736            .absolute()
737            .offset(
738                Some(PANEL_PAD),
739                Some(PANEL_PAD + TAB_BAR_H),
740                Some(PANEL_PAD),
741                Some(PANEL_PAD),
742            )
743            .render_z_index(2000.0))
744        .child(overlay),
745    ))
746}
747
748fn dock_tab_icon_button(
749    label: &'static str,
750    fg: Color,
751    on_click: impl Fn(PointerEvent) + 'static,
752) -> View {
753    Box(
754        Modifier::new()
755            .size(26.0, 26.0)
756            .padding(2.0)
757            .clip_rounded(13.0)
758            .background(fg.with_alpha(18))
759            .clickable()
760            .cursor(CursorIcon::Pointer)
761            .on_pointer_down(on_click),
762    )
763    .child(
764        Box(
765            Modifier::new()
766                .fill_max_size()
767                .content_alignment(Alignment::Center),
768        )
769        .child(Text(label).size(14.0).color(fg)),
770    )
771}
772
773fn dock_drop_overlay(node_id: u64, dock: &DockHandle, key_prefix: &str) -> View {
774    let th = theme();
775
776    if !dock.drag_active.get() {
777        return Box(Modifier::new().hit_passthrough());
778    }
779
780    let zone_dp = 72.0;
781    let hover = dock.hover_sig.get();
782
783    let preview = if let Some(h) = hover.as_ref() {
784        if h.node_id == node_id {
785            dock_drop_preview(h.zone)
786        } else {
787            Box(Modifier::new())
788        }
789    } else {
790        Box(Modifier::new())
791    };
792
793    let mk_zone = |zone: DropZone, m: Modifier| -> View { Box(m.dock_drop_zone(dock, node_id, zone)) };
794
795    // Layout zones using absolute rects (no need for measured size):
796    // left/right/top/bottom thickness = zone_dp; center = remainder.
797    let left = mk_zone(
798        DropZone::Left,
799        Modifier::new()
800            .absolute()
801            .offset(Some(0.0), Some(0.0), None, Some(0.0))
802            .width(zone_dp),
803    );
804
805    let right = mk_zone(
806        DropZone::Right,
807        Modifier::new()
808            .absolute()
809            .offset(None, Some(0.0), Some(0.0), Some(0.0))
810            .width(zone_dp),
811    );
812
813    let top = mk_zone(
814        DropZone::Top,
815        Modifier::new()
816            .absolute()
817            .offset(Some(zone_dp), Some(0.0), Some(zone_dp), None)
818            .height(zone_dp),
819    );
820
821    let bottom = mk_zone(
822        DropZone::Bottom,
823        Modifier::new()
824            .absolute()
825            .offset(Some(zone_dp), None, Some(zone_dp), Some(0.0))
826            .height(zone_dp),
827    );
828
829    let center = mk_zone(
830        DropZone::Center,
831        Modifier::new().absolute().offset(
832            Some(zone_dp),
833            Some(zone_dp),
834            Some(zone_dp),
835            Some(zone_dp),
836        ),
837    );
838
839    ZStack(
840        Modifier::new()
841            .fill_max_size()
842            .key(hash_str_key(key_prefix, node_id)),
843    )
844    .child((
845        // Subtle drag-mode scrim.
846        Box(
847            Modifier::new()
848                .fill_max_size()
849                .background(th.scrim.with_alpha(18))
850                .hit_passthrough()
851                .render_z_index(1000.0),
852        ),
853        Box(Modifier::new()
854            .fill_max_size()
855            .hit_passthrough()
856            .render_z_index(2000.0))
857        .child(preview),
858        left,
859        right,
860        top,
861        bottom,
862        center,
863    ))
864}
865
866fn dock_drop_preview(zone: DropZone) -> View {
867    let th = theme();
868
869    let fill = th
870        .primary
871        .with_alpha(38)
872        .composite_over(th.surface_container_lowest);
873    let border = th.primary.with_alpha(210);
874    let radius = th.shapes.large;
875
876    let card = |label: &'static str, modifier: Modifier| -> View {
877        Box(
878            modifier
879                .clip_rounded(radius)
880                .background(fill)
881                .border(2.0, border, radius),
882        )
883        .child(
884            Box(Modifier::new().padding(12.0)).child(
885                Text(label)
886                    .size(th.typography.label_medium)
887                    .single_line()
888                    .color(th.primary),
889            ),
890        )
891    };
892
893    match zone {
894        DropZone::Center => card(
895            "Add as tab",
896            Modifier::new()
897                .absolute()
898                .offset(Some(14.0), Some(14.0), Some(14.0), Some(14.0)),
899        ),
900
901        DropZone::Left => Row(Modifier::new().fill_max_size().padding(14.0).gap(10.0)).child((
902            card("Split left", Modifier::new().weight(0.44).fill_max_height()),
903            Box(Modifier::new().weight(0.56)),
904        )),
905
906        DropZone::Right => Row(Modifier::new().fill_max_size().padding(14.0).gap(10.0)).child((
907            Box(Modifier::new().weight(0.56)),
908            card("Split right", Modifier::new().weight(0.44).fill_max_height()),
909        )),
910
911        DropZone::Top => Column(
912            Modifier::new()
913                .fill_max_size()
914                .padding(14.0)
915                .gap(10.0),
916        )
917        .child((
918            card("Split top", Modifier::new().weight(0.44).fill_max_width()),
919            Box(Modifier::new().weight(0.56)),
920        )),
921
922        DropZone::Bottom => Column(
923            Modifier::new()
924                .fill_max_size()
925                .padding(14.0)
926                .gap(10.0),
927        )
928        .child((
929            Box(Modifier::new().weight(0.56)),
930            card("Split bottom", Modifier::new().weight(0.44).fill_max_width()),
931        )),
932
933        DropZone::Float => Box(Modifier::new()),
934    }
935}
936
937fn render_split(
938    node_id: u64,
939    dir: SplitDir,
940    ratio: f32,
941    a: &DockNode,
942    b: &DockNode,
943    registry: &Rc<HashMap<PanelId, DockPanel>>,
944    dock: &DockHandle,
945    split_hover: &Signal<Option<u64>>,
946    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
947    key_prefix: &str,
948) -> View {
949    let th = theme();
950    let ratio = ratio.clamp(0.05, 0.95);
951
952    // Track this split container rect so the divider can compute ratio from pointer position.
953    let rect_rc = remember_with_key(format!("dock:split_rect:{}:{node_id}", key_prefix), || {
954        RefCell::new(Rect::default())
955    });
956
957    // Paint-only hook to store rect
958    let track = {
959        let rect_rc = rect_rc.clone();
960        Modifier::new().painter(move |_scene, r, _alpha| {
961            *rect_rc.borrow_mut() = r;
962        })
963    };
964
965    let divider_thick = 8.0;
966
967    let start_drag = {
968        let split_drag = split_drag.clone();
969        move |_pe: PointerEvent| {
970            *split_drag.borrow_mut() = Some(SplitDrag { node_id, dir });
971            request_frame();
972        }
973    };
974
975    let move_drag = {
976        let split_drag = split_drag.clone();
977        let rect_rc = rect_rc.clone();
978        let state = dock.state.clone();
979        move |pe: PointerEvent| {
980            let Some(sd) = split_drag.borrow().clone() else {
981                return;
982            };
983            if sd.node_id != node_id {
984                return;
985            }
986            let r = *rect_rc.borrow();
987            if r.w <= 1.0 || r.h <= 1.0 {
988                return;
989            }
990            let mut t = match dir {
991                SplitDir::Horizontal => (pe.position.x - r.x) / r.w,
992                SplitDir::Vertical => (pe.position.y - r.y) / r.h,
993            };
994            for snap in [0.25_f32, 0.5, 0.75] {
995                if (t - snap).abs() < 0.018 {
996                    t = snap;
997                    break;
998                }
999            }
1000            state.borrow_mut().set_split_ratio(node_id, t);
1001            request_frame();
1002        }
1003    };
1004
1005    let end_drag = {
1006        let split_drag = split_drag.clone();
1007        move |_pe: PointerEvent| {
1008            // end any split drag
1009            *split_drag.borrow_mut() = None;
1010            request_frame();
1011        }
1012    };
1013
1014    // M3-ish splitter: big invisible hit target, subtle tonal gutter,
1015    // rounded grabber only on hover/drag.
1016    let hovered = split_hover.get() == Some(node_id);
1017    let dragging = split_drag
1018        .borrow()
1019        .as_ref()
1020        .map(|sd| sd.node_id == node_id)
1021        .unwrap_or(false);
1022
1023    let active = hovered || dragging;
1024
1025    let gutter_color = if active {
1026        th.primary.with_alpha(24)
1027    } else {
1028        Color::TRANSPARENT
1029    };
1030
1031    let grabber_color = if active {
1032        th.primary
1033    } else {
1034        th.outline_variant.with_alpha(0)
1035    };
1036
1037    let splitter_mod = match dir {
1038        SplitDir::Horizontal => Modifier::new().width(divider_thick).fill_max_height(),
1039        SplitDir::Vertical => Modifier::new().height(divider_thick).fill_max_width(),
1040    };
1041
1042    let grabber = match dir {
1043        SplitDir::Horizontal => Box(
1044            Modifier::new()
1045                .fill_max_size()
1046                .content_alignment(Alignment::Center)
1047                .clip_rounded(4.0),
1048        )
1049        .child(Box(
1050            Modifier::new()
1051                .width(4.0)
1052                .offset(None, Some(24.0), None, Some(24.0))
1053                .background(grabber_color),
1054        )),
1055        SplitDir::Vertical => Box(
1056            Modifier::new()
1057                .fill_max_size()
1058                .content_alignment(Alignment::Center)
1059                .clip_rounded(4.0),
1060        )
1061        .child(Box(
1062            Modifier::new()
1063                .height(4.0)
1064                .offset(Some(24.0), None, Some(24.0), None)
1065                .background(grabber_color),
1066        )),
1067    };
1068
1069    let divider = Box(
1070        splitter_mod
1071            .background(gutter_color)
1072            .on_pointer_enter({
1073                let split_hover = split_hover.clone();
1074                move |_| {
1075                    split_hover.set(Some(node_id));
1076                    request_frame();
1077                }
1078            })
1079            .on_pointer_leave({
1080                let split_hover = split_hover.clone();
1081                move |_| {
1082                    if split_hover.get() == Some(node_id) {
1083                        split_hover.set(None);
1084                        request_frame();
1085                    }
1086                }
1087            })
1088            .on_pointer_down(start_drag)
1089            .on_pointer_move(move_drag)
1090            .on_pointer_up(end_drag)
1091            .cursor(match dir {
1092                SplitDir::Horizontal => CursorIcon::EwResize,
1093                SplitDir::Vertical => CursorIcon::NsResize,
1094            })
1095            .z_index(1500.0)
1096            .render_z_index(1500.0),
1097    )
1098    .child(grabber);
1099
1100    let a_view = render_node(
1101        a,
1102        registry,
1103        dock,
1104        split_hover,
1105        split_drag,
1106        key_prefix,
1107    );
1108    let b_view = render_node(
1109        b,
1110        registry,
1111        dock,
1112        split_hover,
1113        split_drag,
1114        key_prefix,
1115    );
1116
1117    match dir {
1118        SplitDir::Horizontal => Row(track.fill_max_size().key(node_id)).child((
1119            Box(Modifier::new().weight(ratio)).child(a_view),
1120            divider,
1121            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
1122        )),
1123        SplitDir::Vertical => Column(track.fill_max_size().key(node_id)).child((
1124            Box(Modifier::new().weight(ratio)).child(a_view),
1125            divider,
1126            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
1127        )),
1128    }
1129}
1130
1131fn find_node_mut(node: &mut DockNode, id: u64) -> Option<&mut DockNode> {
1132    if node.id == id {
1133        return Some(node);
1134    }
1135    match &mut node.kind {
1136        DockKind::Split { a, b, .. } => find_node_mut(a, id).or_else(|| find_node_mut(b, id)),
1137        _ => None,
1138    }
1139}
1140
1141fn remove_panel_in_node(node: &mut DockNode, pid: PanelId) -> bool {
1142    match &mut node.kind {
1143        DockKind::Empty => false,
1144
1145        DockKind::Tabs { tabs, active } => {
1146            let before = tabs.len();
1147            tabs.retain(|&x| x != pid);
1148            if tabs.len() != before {
1149                if active == &Some(pid) {
1150                    *active = tabs.first().copied();
1151                }
1152                if tabs.is_empty() {
1153                    node.kind = DockKind::Empty;
1154                }
1155                true
1156            } else {
1157                false
1158            }
1159        }
1160
1161        DockKind::Split { a, b, .. } => {
1162            let ra = remove_panel_in_node(a, pid);
1163            let rb = remove_panel_in_node(b, pid);
1164            ra || rb
1165        }
1166    }
1167}
1168
1169fn normalize_node(node: &mut DockNode) {
1170    match &mut node.kind {
1171        DockKind::Empty => {}
1172        DockKind::Tabs { tabs, active } => {
1173            if tabs.is_empty() {
1174                node.kind = DockKind::Empty;
1175            } else if active.is_none() || !tabs.contains(&active.unwrap()) {
1176                *active = tabs.first().copied();
1177            }
1178        }
1179        DockKind::Split { a, b, ratio, .. } => {
1180            *ratio = ratio.clamp(0.05, 0.95);
1181            normalize_node(a);
1182            normalize_node(b);
1183
1184            let a_empty = matches!(a.kind, DockKind::Empty);
1185            let b_empty = matches!(b.kind, DockKind::Empty);
1186
1187            // Collapse empties
1188            if a_empty && !b_empty {
1189                node.kind = std::mem::replace(&mut b.kind, DockKind::Empty);
1190            } else if b_empty && !a_empty {
1191                node.kind = std::mem::replace(&mut a.kind, DockKind::Empty);
1192            } else if a_empty && b_empty {
1193                node.kind = DockKind::Empty;
1194            }
1195        }
1196    }
1197}
1198
1199fn hash_zone_key(node_id: u64, zone: DropZone) -> u64 {
1200    let z = match zone {
1201        DropZone::Center => 1u64,
1202        DropZone::Left => 2,
1203        DropZone::Right => 3,
1204        DropZone::Top => 4,
1205        DropZone::Bottom => 5,
1206        DropZone::Float => 6,
1207    };
1208    node_id ^ (z.wrapping_mul(0x9E3779B97F4A7C15))
1209}
1210
1211fn hash_str_key(prefix: &str, node_id: u64) -> u64 {
1212    let mut h = 1469598103934665603u64;
1213    for b in prefix.as_bytes() {
1214        h ^= *b as u64;
1215        h = h.wrapping_mul(1099511628211u64);
1216    }
1217    h ^ node_id.wrapping_mul(0x9E3779B97F4A7C15)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223
1224    #[test]
1225    fn move_tab_into_center() {
1226        let mut st = DockState::new_with_tabs(vec![1, 2, 3]);
1227        // Create a second tabs node by splitting
1228        assert!(st.dock_panel(1, DropZone::Right, 3));
1229        // Root is now a Split node; docking center into a Split should fail
1230        assert!(!st.dock_panel(st.root.id, DropZone::Center, 2));
1231    }
1232
1233    #[test]
1234    fn remove_collapses_empty_split() {
1235        let mut st = DockState::new_with_tabs(vec![10]);
1236        assert!(st.dock_panel(1, DropZone::Right, 20)); // split created
1237        assert!(st.remove_panel(10));
1238        st.normalize();
1239        // should still not be empty (20 remains)
1240        // root may collapse; ensure at least one tab exists somewhere
1241        fn count_tabs(n: &DockNode) -> usize {
1242            match &n.kind {
1243                DockKind::Tabs { tabs, .. } => tabs.len(),
1244                DockKind::Split { a, b, .. } => count_tabs(a) + count_tabs(b),
1245                DockKind::Empty => 0,
1246            }
1247        }
1248        assert_eq!(count_tabs(&st.root), 1);
1249    }
1250}