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    /// Material Symbol codepoint rendered in the tab strip instead of the
18    /// text title (icon + tooltip model; `title` stays the tooltip +
19    /// palette label).
20    pub icon: Option<char>,
21    pub content: Rc<dyn Fn() -> View>,
22}
23
24#[derive(Clone, Default)]
25pub struct DockCallbacks {
26    /// Optional popout handler. If provided, the docking system will call it
27    /// when a panel is dropped on the "float" target or when user taps popout.
28    pub on_popout: Option<Rc<dyn Fn(PanelId)>>,
29
30    /// Optional close handler (tab close button).
31    pub on_close: Option<Rc<dyn Fn(PanelId)>>,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum SplitDir {
36    Horizontal, // left/right
37    Vertical,   // top/bottom
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum DropZone {
42    Center,
43    Left,
44    Right,
45    Top,
46    Bottom,
47    Float,
48}
49
50/// Persistent docking state.
51/// Store this in `remember_state_with_key(...)` or `SavedState` etc.
52#[derive(Clone)]
53pub struct DockState {
54    pub root: DockNode,
55    next_id: u64,
56}
57
58#[derive(Clone)]
59pub struct DockNode {
60    pub id: u64,
61    pub kind: DockKind,
62}
63
64#[derive(Clone)]
65pub enum DockKind {
66    Empty,
67    Tabs {
68        tabs: Vec<PanelId>,
69        active: Option<PanelId>,
70    },
71    Split {
72        dir: SplitDir,
73        ratio: f32, // 0..1
74        a: Box<DockNode>,
75        b: Box<DockNode>,
76    },
77}
78
79impl DockState {
80    pub fn new_with_tabs(tabs: Vec<PanelId>) -> Self {
81        let mut st = Self {
82            root: DockNode {
83                id: 1,
84                kind: DockKind::Empty,
85            },
86            next_id: 2,
87        };
88        st.root.kind = DockKind::Tabs { tabs, active: None };
89        st.normalize();
90        st
91    }
92
93    /// Create a DockState from a pre-built root node.
94    /// The `max_node_id` should be higher than any node ID used in the tree.
95    pub fn from_root(root: DockNode, max_node_id: u64) -> Self {
96        let mut st = Self {
97            root,
98            next_id: max_node_id + 1,
99        };
100        st.normalize();
101        st
102    }
103
104    fn alloc_id(&mut self) -> u64 {
105        let id = self.next_id;
106        self.next_id += 1;
107        id
108    }
109
110    pub fn normalize(&mut self) {
111        normalize_node(&mut self.root);
112    }
113
114    /// Remove panel without normalizing - for use in compound operations
115    pub fn remove_panel_no_normalize(&mut self, pid: PanelId) -> bool {
116        remove_panel_in_node(&mut self.root, pid)
117    }
118
119    pub fn remove_panel(&mut self, pid: PanelId) -> bool {
120        let removed = remove_panel_in_node(&mut self.root, pid);
121        if removed {
122            normalize_node(&mut self.root);
123        }
124        removed
125    }
126
127    pub fn set_active(&mut self, tabs_node_id: u64, pid: PanelId) {
128        if let Some(n) = find_node_mut(&mut self.root, tabs_node_id)
129            && let DockKind::Tabs { tabs, active } = &mut n.kind
130            && tabs.contains(&pid)
131        {
132            *active = Some(pid);
133        }
134    }
135
136    pub fn set_split_ratio(&mut self, split_node_id: u64, ratio: f32) {
137        let ratio = ratio.clamp(0.05, 0.95);
138        if let Some(n) = find_node_mut(&mut self.root, split_node_id)
139            && let DockKind::Split { ratio: r, .. } = &mut n.kind
140        {
141            *r = ratio;
142        }
143    }
144
145    pub fn dock_panel(&mut self, target_node_id: u64, zone: DropZone, pid: PanelId) -> bool {
146        let prev = find_panel_home(&self.root, pid);
147        let was_present = prev.is_some();
148        self.remove_panel_no_normalize(pid);
149
150        let result = match zone {
151            DropZone::Center => self.insert_as_tab(target_node_id, pid),
152            DropZone::Left | DropZone::Right | DropZone::Top | DropZone::Bottom => {
153                self.insert_as_split(target_node_id, zone, pid)
154            }
155            DropZone::Float => false,
156        };
157
158        if !result {
159            if was_present {
160                self.restore_panel(pid, prev);
161            } else {
162                self.remove_panel_no_normalize(pid);
163            }
164        }
165        self.normalize();
166        result
167    }
168
169    /// Re-insert a panel that a rejected drop removed. Prefers its previous
170    /// home; falls back to the root / first available tabs node.
171    fn restore_panel(&mut self, pid: PanelId, prev: Option<(u64, usize)>) {
172        if let Some((home_id, idx)) = prev
173            && let Some(n) = find_node_mut(&mut self.root, home_id)
174            && let DockKind::Tabs { tabs, active } = &mut n.kind
175        {
176            let at = idx.min(tabs.len());
177            if !tabs.contains(&pid) {
178                tabs.insert(at, pid);
179            }
180            if active.is_none() {
181                *active = Some(pid);
182            }
183            return;
184        }
185        if let DockKind::Tabs { tabs, active } = &mut self.root.kind {
186            if !tabs.contains(&pid) {
187                tabs.push(pid);
188            }
189            if active.is_none() {
190                *active = Some(pid);
191            }
192            return;
193        }
194        if insert_into_first_tabs(&mut self.root, pid) {
195            return;
196        }
197        self.root.kind = DockKind::Tabs {
198            tabs: vec![pid],
199            active: Some(pid),
200        };
201    }
202
203    fn insert_as_tab(&mut self, target_node_id: u64, pid: PanelId) -> bool {
204        let Some(n) = find_node_mut(&mut self.root, target_node_id) else {
205            return false;
206        };
207
208        match &mut n.kind {
209            DockKind::Tabs { tabs, active } => {
210                if !tabs.contains(&pid) {
211                    tabs.push(pid);
212                }
213                *active = Some(pid);
214                self.normalize();
215                true
216            }
217            DockKind::Empty => {
218                n.kind = DockKind::Tabs {
219                    tabs: vec![pid],
220                    active: Some(pid),
221                };
222                self.normalize();
223                true
224            }
225            DockKind::Split { .. } => false,
226        }
227    }
228
229    fn insert_as_split(&mut self, target_node_id: u64, zone: DropZone, pid: PanelId) -> bool {
230        if find_node(&self.root, target_node_id).is_none() {
231            return false;
232        }
233
234        let new_tabs_id = self.alloc_id();
235        let new_split_id = self.alloc_id();
236
237        let Some(n) = find_node_mut(&mut self.root, target_node_id) else {
238            return false;
239        };
240
241        let old_kind = std::mem::replace(&mut n.kind, DockKind::Empty);
242
243        let dir = match zone {
244            DropZone::Left | DropZone::Right => SplitDir::Horizontal,
245            DropZone::Top | DropZone::Bottom => SplitDir::Vertical,
246            _ => SplitDir::Horizontal,
247        };
248
249        let new_tabs = DockNode {
250            id: new_tabs_id,
251            kind: DockKind::Tabs {
252                tabs: vec![pid],
253                active: Some(pid),
254            },
255        };
256
257        // Old content KEEPS the original target_node_id
258        let old_node = DockNode {
259            id: target_node_id,
260            kind: old_kind,
261        };
262
263        let (a, b) = match zone {
264            DropZone::Left | DropZone::Top => (Box::new(new_tabs), Box::new(old_node)),
265            DropZone::Right | DropZone::Bottom => (Box::new(old_node), Box::new(new_tabs)),
266            _ => (Box::new(old_node), Box::new(new_tabs)),
267        };
268
269        // The node at this position becomes a split with a NEW ID
270        n.id = new_split_id;
271        n.kind = DockKind::Split {
272            dir,
273            ratio: 0.5,
274            a,
275            b,
276        };
277
278        self.normalize();
279        true
280    }
281}
282
283#[derive(Clone, Debug)]
284pub struct DockTabPayload {
285    pub panel_id: PanelId,
286}
287
288#[derive(Clone, Debug, PartialEq, Eq)]
289struct HoverHint {
290    node_id: u64,
291    zone: DropZone,
292}
293
294#[derive(Clone)]
295struct SplitDrag {
296    node_id: u64,
297}
298
299/// Ephemeral, reusable dock behavior handle. Created by [`remember_dock_handle`]
300/// and passed to the [`DockModifierExt`] helpers so custom chrome can reuse the
301/// exact same docking behavior.
302#[derive(Clone)]
303pub struct DockHandle {
304    pub(crate) state: Rc<RefCell<DockState>>,
305    pub(crate) callbacks: DockCallbacks,
306    pub(crate) hover_sig: Signal<Option<HoverHint>>,
307    pub(crate) tab_hover: Signal<Option<PanelId>>,
308    pub(crate) drag_active: Signal<bool>,
309}
310
311/// Create a [`DockHandle`] bound to a dock state and callbacks. Remember this in
312/// your widget so it survives recompositions.
313pub fn remember_dock_handle(
314    key: impl Into<String>,
315    state: Rc<RefCell<DockState>>,
316    callbacks: DockCallbacks,
317) -> DockHandle {
318    let key = key.into();
319
320    let hover_sig = remember_with_key(format!("dock:hover:{key}"), || signal(None::<HoverHint>));
321    let tab_hover = remember_with_key(format!("dock:tab_hover:{key}"), || signal(None::<PanelId>));
322    let drag_active = remember_with_key(format!("dock:drag_active:{key}"), || signal(false));
323
324    DockHandle {
325        state,
326        callbacks,
327        hover_sig: (*hover_sig).clone(),
328        tab_hover: (*tab_hover).clone(),
329        drag_active: (*drag_active).clone(),
330    }
331}
332
333/// Modular dock behavior modifiers. Lets custom chrome reuse docking behavior:
334///
335/// ```ignore
336/// use repose_docking::{DockModifierExt, DockHandle};
337/// Row(Modifier::new().dock_tab_source(&dock, panel_id)).child(...)
338/// ```
339pub trait DockModifierExt: Sized {
340    /// Make this node a draggable dock-tab source.
341    fn dock_tab_source(self, dock: &DockHandle, panel_id: PanelId) -> Modifier;
342
343    /// Make a tab strip accept dropped tabs for reorder/insert.
344    fn dock_tab_strip_drop_target(
345        self,
346        dock: &DockHandle,
347        node_id: u64,
348        tabbar_rect: Rc<RefCell<Rect>>,
349    ) -> Modifier;
350
351    /// Make this node one specific dock drop zone.
352    fn dock_drop_zone(self, dock: &DockHandle, node_id: u64, zone: DropZone) -> Modifier;
353
354    /// Make this node the outer "float/popout" target.
355    fn dock_float_target(self, dock: &DockHandle) -> Modifier;
356}
357
358impl DockModifierExt for Modifier {
359    fn dock_tab_source(self, dock: &DockHandle, panel_id: PanelId) -> Modifier {
360        let drag_active_start = dock.drag_active.clone();
361
362        let hover_end = dock.hover_sig.clone();
363        let drag_active_end = dock.drag_active.clone();
364
365        self.cursor(CursorIcon::Grab)
366            .drag_source::<DockTabPayload>(move |_start| {
367                drag_active_start.set(true);
368                Some(DockTabPayload { panel_id })
369            })
370            .on_drag_end(move |_end| {
371                drag_active_end.set(false);
372                hover_end.set(None);
373            })
374    }
375
376    fn dock_tab_strip_drop_target(
377        self,
378        dock: &DockHandle,
379        node_id: u64,
380        tabbar_rect: Rc<RefCell<Rect>>,
381    ) -> Modifier {
382        let state = dock.state.clone();
383        let hover_sig = dock.hover_sig.clone();
384        let drag_active = dock.drag_active.clone();
385
386        self.on_drop_typed::<DockTabPayload>(move |ev, p| {
387            let mut st = state.borrow_mut();
388
389            // Preserve current logic: remove without normalizing so node_id remains valid.
390            st.remove_panel_no_normalize(p.panel_id);
391
392            let r = *tabbar_rect.borrow();
393            let t = if r.w > 1.0 {
394                ((ev.position.x - r.x) / r.w).clamp(0.0, 1.0)
395            } else {
396                1.0
397            };
398
399            if let Some(n) = find_node_mut(&mut st.root, node_id) {
400                if matches!(n.kind, DockKind::Empty) {
401                    n.kind = DockKind::Tabs {
402                        tabs: Vec::new(),
403                        active: None,
404                    };
405                }
406
407                if let DockKind::Tabs { tabs, active } = &mut n.kind {
408                    tabs.retain(|&x| x != p.panel_id);
409                    let idx = ((t * (tabs.len() as f32 + 1.0)).floor() as usize).min(tabs.len());
410                    tabs.insert(idx, p.panel_id);
411                    *active = Some(p.panel_id);
412                }
413            }
414
415            st.normalize();
416            hover_sig.set(None);
417            drag_active.set(false);
418            request_frame();
419            true
420        })
421    }
422
423    fn dock_drop_zone(self, dock: &DockHandle, node_id: u64, zone: DropZone) -> Modifier {
424        let hover_enter = dock.hover_sig.clone();
425        let hover_over = dock.hover_sig.clone();
426        let hover_leave = dock.hover_sig.clone();
427        let hover_drop = dock.hover_sig.clone();
428        let state = dock.state.clone();
429
430        self.z_index(3000.0)
431            .render_z_index(3000.0)
432            .key(hash_zone_key(node_id, zone))
433            .on_drag_enter_typed::<DockTabPayload>(move |_ev, _p| {
434                hover_enter.set(Some(HoverHint { node_id, zone }));
435            })
436            .on_drag_over_typed::<DockTabPayload>(move |_ev, _p| {
437                hover_over.set(Some(HoverHint { node_id, zone }));
438            })
439            .on_drag_leave_typed::<DockTabPayload>(move |_ev, _p| {
440                if hover_leave.get().as_ref() == Some(&HoverHint { node_id, zone }) {
441                    hover_leave.set(None);
442                }
443            })
444            .on_drop_typed::<DockTabPayload>(move |_ev, p| {
445                let ok = state.borrow_mut().dock_panel(node_id, zone, p.panel_id);
446                hover_drop.set(None);
447                request_frame();
448                ok
449            })
450    }
451
452    fn dock_float_target(self, dock: &DockHandle) -> Modifier {
453        let state = dock.state.clone();
454        let hover_sig = dock.hover_sig.clone();
455        let cb_pop = dock.callbacks.on_popout.clone();
456
457        self.on_drop_typed::<DockTabPayload>(move |_ev, p| {
458            let Some(pop) = cb_pop.as_ref() else {
459                return false;
460            };
461
462            state.borrow_mut().remove_panel(p.panel_id);
463            pop(p.panel_id);
464            hover_sig.set(None);
465            request_frame();
466            true
467        })
468    }
469}
470
471pub fn DockArea(
472    key: impl Into<String>,
473    modifier: Modifier,
474    state: Rc<RefCell<DockState>>,
475    panels: Vec<DockPanel>,
476    callbacks: DockCallbacks,
477) -> View {
478    let key = key.into();
479    let registry = Rc::new(build_registry(panels));
480
481    let dock = remember_dock_handle(key.clone(), state, callbacks);
482
483    let split_hover = remember_with_key(format!("dock:split_hover:{key}"), || signal(None::<u64>));
484    let split_drag = remember_with_key(format!("dock:split_drag:{key}"), || {
485        RefCell::new(None::<SplitDrag>)
486    });
487
488    // Outer "float" drop target: if you drop a tab anywhere not handled by inner targets.
489    // We set z-index low so inner targets win.
490    let float_target = Box(Modifier::new()
491        .fill_max_size()
492        .z_index(-1000.0)
493        .dock_float_target(&dock));
494
495    // Actual docking UI
496    let root_view = {
497        let st = dock.state.borrow().clone();
498        render_node(
499            &st.root,
500            &registry,
501            &dock,
502            &split_hover,
503            &split_drag,
504            key.as_str(),
505        )
506    };
507
508    ZStack(modifier.fill_max_size()).child((
509        Box(Modifier::new().absolute().offset(
510            Some(Dp(0.0)),
511            Some(Dp(0.0)),
512            Some(Dp(0.0)),
513            Some(Dp(0.0)),
514        ))
515        .child(float_target),
516        Box(Modifier::new().absolute().offset(
517            Some(Dp(0.0)),
518            Some(Dp(0.0)),
519            Some(Dp(0.0)),
520            Some(Dp(0.0)),
521        ))
522        .child(root_view),
523    ))
524}
525
526fn build_registry(panels: Vec<DockPanel>) -> HashMap<PanelId, DockPanel> {
527    let mut m = HashMap::new();
528    for p in panels {
529        m.insert(p.id, p);
530    }
531    m
532}
533
534fn render_node(
535    node: &DockNode,
536    registry: &Rc<HashMap<PanelId, DockPanel>>,
537    dock: &DockHandle,
538    split_hover: &Signal<Option<u64>>,
539    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
540    key_prefix: &str,
541) -> View {
542    match &node.kind {
543        DockKind::Empty => Box(Modifier::new()
544            .fill_max_size()
545            .padding(Dp(6.0))
546            .background(theme().surface_container_lowest)
547            .clip_rounded(theme().shapes.medium)
548            .border(
549                Dp(1.0),
550                theme().outline_variant.with_alpha(80),
551                theme().shapes.medium,
552            )
553            .key(node.id))
554        .child(
555            Box(Modifier::new().fill_max_size().padding(Dp(16.0))).child(
556                Text("Drop panel here")
557                    .size(theme().typography.label_medium)
558                    .color(theme().on_surface_variant),
559            ),
560        ),
561
562        DockKind::Tabs { tabs, active } => render_tabs(
563            node.id,
564            tabs,
565            *active,
566            registry,
567            dock,
568            split_hover,
569            key_prefix,
570        ),
571
572        DockKind::Split { dir, ratio, a, b } => render_split(
573            node.id,
574            *dir,
575            *ratio,
576            a,
577            b,
578            registry,
579            dock,
580            split_hover,
581            split_drag,
582            key_prefix,
583        ),
584    }
585}
586
587fn render_tabs(
588    node_id: u64,
589    tabs: &[PanelId],
590    active: Option<PanelId>,
591    registry: &Rc<HashMap<PanelId, DockPanel>>,
592    dock: &DockHandle,
593    _split_hover: &Signal<Option<u64>>,
594    key_prefix: &str,
595) -> View {
596    let th = theme();
597
598    const PANEL_PAD: Dp = Dp(5.0);
599    const TAB_BAR_H: Dp = Dp(44.0);
600    const TAB_H: Dp = Dp(32.0);
601    const TAB_RADIUS: Dp = Dp(16.0);
602
603    // Ensure active is valid
604    let active_pid = active.or_else(|| tabs.first().copied());
605
606    let tabbar_rect = remember_with_key(format!("dock:tabbar_rect:{key_prefix}:{node_id}"), || {
607        RefCell::new(Rect::default())
608    });
609
610    let strip_bg = th.surface_container_low;
611    let active_bg = th.secondary_container;
612    let active_fg = th.on_secondary_container;
613    let inactive_fg = th.on_surface_variant;
614    let hover_bg = th.surface_container_high;
615
616    let mut bar_mod = Modifier::new()
617        .fill_max_width()
618        .height(TAB_BAR_H)
619        .background(strip_bg)
620        .padding_values(PaddingValues {
621            left: Dp(8.0),
622            right: Dp(8.0),
623            top: Dp(6.0),
624            bottom: Dp(6.0),
625        })
626        .gap(Dp(6.0))
627        .painter({
628            let tabbar_rect = tabbar_rect.clone();
629            move |_scene, r, _alpha| *tabbar_rect.borrow_mut() = r
630        });
631
632    if dock.drag_active.get() {
633        bar_mod = bar_mod.dock_tab_strip_drop_target(dock, node_id, tabbar_rect.clone());
634    }
635
636    let tab_bar = Row(bar_mod).with_children(
637        tabs.iter()
638            .copied()
639            .filter_map(|pid| {
640                let panel = registry.get(&pid)?;
641                let is_active = Some(pid) == active_pid;
642                let is_hovered = dock.tab_hover.get() == Some(pid);
643
644                let state_set = dock.state.clone();
645                let title = panel.title.clone();
646                let icon = panel.icon;
647                let drag_pid = pid;
648
649                let cb_close = dock.callbacks.on_close.clone();
650                let cb_pop = dock.callbacks.on_popout.clone();
651
652                let tab_bg = if is_active {
653                    active_bg
654                } else if is_hovered {
655                    hover_bg
656                } else {
657                    Color::TRANSPARENT
658                };
659
660                let tab_fg = if is_active { active_fg } else { inactive_fg };
661
662                let hover_in = {
663                    let tab_hover = dock.tab_hover.clone();
664                    move |_| tab_hover.set(Some(pid))
665                };
666
667                let hover_out = {
668                    let tab_hover = dock.tab_hover.clone();
669                    move |_| {
670                        if tab_hover.get() == Some(pid) {
671                            tab_hover.set(None);
672                        }
673                    }
674                };
675
676                let pop_view = if let Some(pop) = cb_pop {
677                    let state_for_pop = dock.state.clone();
678                    dock_tab_icon_button("↗", tab_fg, move |_| {
679                        state_for_pop.borrow_mut().remove_panel(pid);
680                        pop(pid);
681                        request_frame();
682                    })
683                } else {
684                    Box(Modifier::new())
685                };
686
687                let close_view = if let Some(close) = cb_close {
688                    dock_tab_icon_button("×", tab_fg, move |_| {
689                        close(pid);
690                        request_frame();
691                    })
692                } else {
693                    Box(Modifier::new())
694                };
695
696                Some(
697                    Row(Modifier::new()
698                        .key(pid)
699                        .height(TAB_H)
700                        .min_width(Dp(44.0))
701                        .max_width(Dp(240.0))
702                        .clip_rounded(TAB_RADIUS)
703                        .background(tab_bg)
704                        .padding_values(PaddingValues {
705                            left: Dp(12.0),
706                            right: Dp(4.0),
707                            top: Dp(0.0),
708                            bottom: Dp(0.0),
709                        })
710                        .gap(Dp(4.0))
711                        .clickable()
712                        .on_pointer_enter(hover_in)
713                        .on_pointer_leave(hover_out)
714                        .on_pointer_down({
715                            let state_set = state_set.clone();
716                            move |_| {
717                                state_set.borrow_mut().set_active(node_id, pid);
718                                request_frame();
719                            }
720                        })
721                        .drag_preview_chip(title.clone(), th.primary)
722                        .dock_tab_source(dock, drag_pid))
723                    .child((
724                        Box(Modifier::new()
725                            .height(TAB_H)
726                            .weight(1.0)
727                            .padding_values(PaddingValues {
728                                left: Dp(0.0),
729                                right: Dp(4.0),
730                                top: Dp(0.0),
731                                bottom: Dp(0.0),
732                            })
733                            .content_alignment(Alignment::Center))
734                        .child(if let Some(glyph) = icon {
735                            Text(glyph.to_string())
736                                .size(Sp(18.0))
737                                .font_family("Material Symbols Outlined")
738                                .color(tab_fg)
739                        } else {
740                            Text(title)
741                                .size(th.typography.label_large)
742                                .single_line()
743                                .overflow_ellipsize()
744                                .color(tab_fg)
745                        }),
746                        pop_view,
747                        close_view,
748                    )),
749                )
750            })
751            .collect::<Vec<_>>(),
752    );
753
754    // Content
755    let content = if let Some(pid) = active_pid {
756        if let Some(panel) = registry.get(&pid) {
757            (panel.content)()
758        } else {
759            Text("Missing panel").color(th.error)
760        }
761    } else {
762        Text("No tabs").color(th.on_surface_variant)
763    };
764
765    // Drop zones overlay (present only while dragging)
766    let overlay = dock_drop_overlay(node_id, dock, key_prefix);
767
768    ZStack(Modifier::new().fill_max_size().key(node_id)).child((
769        Column(
770            Modifier::new()
771                .fill_max_size()
772                .padding(PANEL_PAD)
773                .clip_rounded(th.shapes.medium)
774                .background(th.surface_container_lowest)
775                .border(Dp(1.0), th.outline_variant.with_alpha(70), th.shapes.medium),
776        )
777        .child((
778            tab_bar,
779            Box(Modifier::new()
780                .fill_max_size()
781                .background(th.surface_container_lowest))
782            .child(Box(Modifier::new().fill_max_size().padding(Dp(8.0))).child(content)),
783        )),
784        Box(Modifier::new()
785            .absolute()
786            .offset(
787                Some(PANEL_PAD),
788                Some(PANEL_PAD + TAB_BAR_H),
789                Some(PANEL_PAD),
790                Some(PANEL_PAD),
791            )
792            .render_z_index(2000.0))
793        .child(overlay),
794    ))
795}
796
797fn dock_tab_icon_button(
798    label: &'static str,
799    fg: Color,
800    on_click: impl Fn(PointerEvent) + 'static,
801) -> View {
802    Box(Modifier::new()
803        .size(Dp(26.0), Dp(26.0))
804        .padding(Dp(2.0))
805        .clip_rounded(Dp(13.0))
806        .background(fg.with_alpha(18))
807        .clickable()
808        .cursor(CursorIcon::Pointer)
809        .on_pointer_down(on_click))
810    .child(
811        Box(Modifier::new()
812            .fill_max_size()
813            .content_alignment(Alignment::Center))
814        .child(Text(label).size(Sp(14.0)).color(fg)),
815    )
816}
817
818fn dock_drop_overlay(node_id: u64, dock: &DockHandle, key_prefix: &str) -> View {
819    let th = theme();
820
821    if !dock.drag_active.get() {
822        return Box(Modifier::new().hit_passthrough());
823    }
824
825    let zone_dp = Dp(72.0);
826    let hover = dock.hover_sig.get();
827
828    let preview = if let Some(h) = hover.as_ref() {
829        if h.node_id == node_id {
830            dock_drop_preview(h.zone)
831        } else {
832            Box(Modifier::new())
833        }
834    } else {
835        Box(Modifier::new())
836    };
837
838    let mk_zone =
839        |zone: DropZone, m: Modifier| -> View { Box(m.dock_drop_zone(dock, node_id, zone)) };
840
841    // Layout zones using absolute rects (no need for measured size):
842    // left/right/top/bottom thickness = zone_dp; center = remainder.
843    let left = mk_zone(
844        DropZone::Left,
845        Modifier::new()
846            .absolute()
847            .offset(Some(Dp(0.0)), Some(Dp(0.0)), None, Some(Dp(0.0)))
848            .width(zone_dp),
849    );
850
851    let right = mk_zone(
852        DropZone::Right,
853        Modifier::new()
854            .absolute()
855            .offset(None, Some(Dp(0.0)), Some(Dp(0.0)), Some(Dp(0.0)))
856            .width(zone_dp),
857    );
858
859    let top = mk_zone(
860        DropZone::Top,
861        Modifier::new()
862            .absolute()
863            .offset(Some(zone_dp), Some(Dp(0.0)), Some(zone_dp), None)
864            .height(zone_dp),
865    );
866
867    let bottom = mk_zone(
868        DropZone::Bottom,
869        Modifier::new()
870            .absolute()
871            .offset(Some(zone_dp), None, Some(zone_dp), Some(Dp(0.0)))
872            .height(zone_dp),
873    );
874
875    let center = mk_zone(
876        DropZone::Center,
877        Modifier::new().absolute().offset(
878            Some(zone_dp),
879            Some(zone_dp),
880            Some(zone_dp),
881            Some(zone_dp),
882        ),
883    );
884
885    ZStack(
886        Modifier::new()
887            .fill_max_size()
888            .key(hash_str_key(key_prefix, node_id)),
889    )
890    .child((
891        // Subtle drag-mode scrim.
892        Box(Modifier::new()
893            .fill_max_size()
894            .background(th.scrim.with_alpha(18))
895            .hit_passthrough()
896            .render_z_index(1000.0)),
897        Box(Modifier::new()
898            .fill_max_size()
899            .hit_passthrough()
900            .render_z_index(2000.0))
901        .child(preview),
902        left,
903        right,
904        top,
905        bottom,
906        center,
907    ))
908}
909
910fn dock_drop_preview(zone: DropZone) -> View {
911    let th = theme();
912
913    let fill = th
914        .primary
915        .with_alpha(38)
916        .composite_over(th.surface_container_lowest);
917    let border = th.primary.with_alpha(210);
918    let radius = th.shapes.large;
919
920    let card = |label: &'static str, modifier: Modifier| -> View {
921        Box(modifier
922            .clip_rounded(radius)
923            .background(fill)
924            .border(Dp(2.0), border, radius))
925        .child(
926            Box(Modifier::new().padding(Dp(12.0))).child(
927                Text(label)
928                    .size(th.typography.label_medium)
929                    .single_line()
930                    .color(th.primary),
931            ),
932        )
933    };
934
935    match zone {
936        DropZone::Center => card(
937            "Add as tab",
938            Modifier::new().absolute().offset(
939                Some(Dp(14.0)),
940                Some(Dp(14.0)),
941                Some(Dp(14.0)),
942                Some(Dp(14.0)),
943            ),
944        ),
945
946        DropZone::Left => Row(Modifier::new()
947            .fill_max_size()
948            .padding(Dp(14.0))
949            .gap(Dp(10.0)))
950        .child((
951            card("Split left", Modifier::new().weight(0.44).fill_max_height()),
952            Box(Modifier::new().weight(0.56)),
953        )),
954
955        DropZone::Right => Row(Modifier::new()
956            .fill_max_size()
957            .padding(Dp(14.0))
958            .gap(Dp(10.0)))
959        .child((
960            Box(Modifier::new().weight(0.56)),
961            card(
962                "Split right",
963                Modifier::new().weight(0.44).fill_max_height(),
964            ),
965        )),
966
967        DropZone::Top => Column(
968            Modifier::new()
969                .fill_max_size()
970                .padding(Dp(14.0))
971                .gap(Dp(10.0)),
972        )
973        .child((
974            card("Split top", Modifier::new().weight(0.44).fill_max_width()),
975            Box(Modifier::new().weight(0.56)),
976        )),
977
978        DropZone::Bottom => Column(
979            Modifier::new()
980                .fill_max_size()
981                .padding(Dp(14.0))
982                .gap(Dp(10.0)),
983        )
984        .child((
985            Box(Modifier::new().weight(0.56)),
986            card(
987                "Split bottom",
988                Modifier::new().weight(0.44).fill_max_width(),
989            ),
990        )),
991
992        DropZone::Float => Box(Modifier::new()),
993    }
994}
995
996fn render_split(
997    node_id: u64,
998    dir: SplitDir,
999    ratio: f32,
1000    a: &DockNode,
1001    b: &DockNode,
1002    registry: &Rc<HashMap<PanelId, DockPanel>>,
1003    dock: &DockHandle,
1004    split_hover: &Signal<Option<u64>>,
1005    split_drag: &Rc<RefCell<Option<SplitDrag>>>,
1006    key_prefix: &str,
1007) -> View {
1008    let th = theme();
1009    let ratio = ratio.clamp(0.05, 0.95);
1010
1011    // Track this split container rect so the divider can compute ratio from pointer position.
1012    let rect_rc = remember_with_key(format!("dock:split_rect:{}:{node_id}", key_prefix), || {
1013        RefCell::new(Rect::default())
1014    });
1015
1016    // Paint-only hook to store rect
1017    let track = {
1018        let rect_rc = rect_rc.clone();
1019        Modifier::new().painter(move |_scene, r, _alpha| {
1020            *rect_rc.borrow_mut() = r;
1021        })
1022    };
1023
1024    let divider_thick = Dp(8.0);
1025
1026    let start_drag = {
1027        let split_drag = split_drag.clone();
1028        move |_pe: PointerEvent| {
1029            *split_drag.borrow_mut() = Some(SplitDrag { node_id });
1030            request_frame();
1031        }
1032    };
1033
1034    let move_drag = {
1035        let split_drag = split_drag.clone();
1036        let rect_rc = rect_rc.clone();
1037        let state = dock.state.clone();
1038        move |pe: PointerEvent| {
1039            let Some(sd) = split_drag.borrow().clone() else {
1040                return;
1041            };
1042            if sd.node_id != node_id {
1043                return;
1044            }
1045            let r = *rect_rc.borrow();
1046            if r.w <= 1.0 || r.h <= 1.0 {
1047                return;
1048            }
1049            let mut t = match dir {
1050                SplitDir::Horizontal => (pe.position_in_window().x - r.x) / r.w,
1051                SplitDir::Vertical => (pe.position_in_window().y - r.y) / r.h,
1052            };
1053            for snap in [0.25_f32, 0.5, 0.75] {
1054                if (t - snap).abs() < 0.018 {
1055                    t = snap;
1056                    break;
1057                }
1058            }
1059            state.borrow_mut().set_split_ratio(node_id, t);
1060            request_frame();
1061        }
1062    };
1063
1064    let end_drag = {
1065        let split_drag = split_drag.clone();
1066        move |_pe: PointerEvent| {
1067            // end any split drag
1068            *split_drag.borrow_mut() = None;
1069            request_frame();
1070        }
1071    };
1072
1073    // M3-ish splitter: big invisible hit target, subtle tonal gutter,
1074    // rounded grabber only on hover/drag.
1075    let hovered = split_hover.get() == Some(node_id);
1076    let dragging = split_drag
1077        .borrow()
1078        .as_ref()
1079        .map(|sd| sd.node_id == node_id)
1080        .unwrap_or(false);
1081
1082    let active = hovered || dragging;
1083
1084    let gutter_color = if active {
1085        th.primary.with_alpha(24)
1086    } else {
1087        Color::TRANSPARENT
1088    };
1089
1090    let grabber_color = if active {
1091        th.primary
1092    } else {
1093        th.outline_variant.with_alpha(0)
1094    };
1095
1096    let splitter_mod = match dir {
1097        SplitDir::Horizontal => Modifier::new().width(divider_thick).fill_max_height(),
1098        SplitDir::Vertical => Modifier::new().height(divider_thick).fill_max_width(),
1099    };
1100
1101    let grabber = match dir {
1102        SplitDir::Horizontal => Box(Modifier::new()
1103            .fill_max_size()
1104            .content_alignment(Alignment::Center)
1105            .clip_rounded(Dp(4.0)))
1106        .child(Box(Modifier::new()
1107            .width(Dp(4.0))
1108            .offset(None, Some(Dp(24.0)), None, Some(Dp(24.0)))
1109            .background(grabber_color))),
1110        SplitDir::Vertical => Box(Modifier::new()
1111            .fill_max_size()
1112            .content_alignment(Alignment::Center)
1113            .clip_rounded(Dp(4.0)))
1114        .child(Box(Modifier::new()
1115            .height(Dp(4.0))
1116            .offset(Some(Dp(24.0)), None, Some(Dp(24.0)), None)
1117            .background(grabber_color))),
1118    };
1119
1120    let divider = Box(splitter_mod
1121        .background(gutter_color)
1122        .on_pointer_enter({
1123            let split_hover = split_hover.clone();
1124            move |_| {
1125                split_hover.set(Some(node_id));
1126                request_frame();
1127            }
1128        })
1129        .on_pointer_leave({
1130            let split_hover = split_hover.clone();
1131            move |_| {
1132                if split_hover.get() == Some(node_id) {
1133                    split_hover.set(None);
1134                    request_frame();
1135                }
1136            }
1137        })
1138        .on_pointer_down(start_drag)
1139        .on_pointer_move(move_drag)
1140        .on_pointer_up(end_drag)
1141        .cursor(match dir {
1142            SplitDir::Horizontal => CursorIcon::EwResize,
1143            SplitDir::Vertical => CursorIcon::NsResize,
1144        })
1145        .z_index(1500.0)
1146        .render_z_index(1500.0))
1147    .child(grabber);
1148
1149    let a_view = render_node(a, registry, dock, split_hover, split_drag, key_prefix);
1150    let b_view = render_node(b, registry, dock, split_hover, split_drag, key_prefix);
1151
1152    match dir {
1153        SplitDir::Horizontal => Row(track.fill_max_size().key(node_id)).child((
1154            Box(Modifier::new().weight(ratio)).child(a_view),
1155            divider,
1156            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
1157        )),
1158        SplitDir::Vertical => Column(track.fill_max_size().key(node_id)).child((
1159            Box(Modifier::new().weight(ratio)).child(a_view),
1160            divider,
1161            Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
1162        )),
1163    }
1164}
1165
1166/// Locate the tabs node holding `pid`: (node id, index within tabs).
1167fn find_panel_home(node: &DockNode, pid: PanelId) -> Option<(u64, usize)> {
1168    match &node.kind {
1169        DockKind::Tabs { tabs, .. } => tabs.iter().position(|t| *t == pid).map(|i| (node.id, i)),
1170        DockKind::Split { a, b, .. } => find_panel_home(a, pid).or_else(|| find_panel_home(b, pid)),
1171        DockKind::Empty => None,
1172    }
1173}
1174
1175fn insert_into_first_tabs(node: &mut DockNode, pid: PanelId) -> bool {
1176    match &mut node.kind {
1177        DockKind::Tabs { tabs, active } => {
1178            if !tabs.contains(&pid) {
1179                tabs.push(pid);
1180            }
1181            if active.is_none() {
1182                *active = Some(pid);
1183            }
1184            true
1185        }
1186        DockKind::Split { a, b, .. } => {
1187            insert_into_first_tabs(a, pid) || insert_into_first_tabs(b, pid)
1188        }
1189        DockKind::Empty => false,
1190    }
1191}
1192
1193fn find_node<'a>(node: &'a DockNode, id: u64) -> Option<&'a DockNode> {
1194    if node.id == id {
1195        return Some(node);
1196    }
1197    match &node.kind {
1198        DockKind::Split { a, b, .. } => find_node(a, id).or_else(|| find_node(b, id)),
1199        _ => None,
1200    }
1201}
1202
1203fn find_node_mut(node: &mut DockNode, id: u64) -> Option<&mut DockNode> {
1204    if node.id == id {
1205        return Some(node);
1206    }
1207    match &mut node.kind {
1208        DockKind::Split { a, b, .. } => find_node_mut(a, id).or_else(|| find_node_mut(b, id)),
1209        _ => None,
1210    }
1211}
1212
1213fn remove_panel_in_node(node: &mut DockNode, pid: PanelId) -> bool {
1214    match &mut node.kind {
1215        DockKind::Empty => false,
1216
1217        DockKind::Tabs { tabs, active } => {
1218            let before = tabs.len();
1219            tabs.retain(|&x| x != pid);
1220            if tabs.len() != before {
1221                if active == &Some(pid) {
1222                    *active = tabs.first().copied();
1223                }
1224                if tabs.is_empty() {
1225                    node.kind = DockKind::Empty;
1226                }
1227                true
1228            } else {
1229                false
1230            }
1231        }
1232
1233        DockKind::Split { a, b, .. } => {
1234            let ra = remove_panel_in_node(a, pid);
1235            let rb = remove_panel_in_node(b, pid);
1236            ra || rb
1237        }
1238    }
1239}
1240
1241fn normalize_node(node: &mut DockNode) {
1242    match &mut node.kind {
1243        DockKind::Empty => {}
1244        DockKind::Tabs { tabs, active } => {
1245            if tabs.is_empty() {
1246                node.kind = DockKind::Empty;
1247            } else if active.is_none() || !tabs.contains(&active.unwrap()) {
1248                *active = tabs.first().copied();
1249            }
1250        }
1251        DockKind::Split { a, b, ratio, .. } => {
1252            *ratio = ratio.clamp(0.05, 0.95);
1253            normalize_node(a);
1254            normalize_node(b);
1255
1256            let a_empty = matches!(a.kind, DockKind::Empty);
1257            let b_empty = matches!(b.kind, DockKind::Empty);
1258
1259            // Collapse empties
1260            if a_empty && !b_empty {
1261                node.kind = std::mem::replace(&mut b.kind, DockKind::Empty);
1262            } else if b_empty && !a_empty {
1263                node.kind = std::mem::replace(&mut a.kind, DockKind::Empty);
1264            } else if a_empty && b_empty {
1265                node.kind = DockKind::Empty;
1266            }
1267        }
1268    }
1269}
1270
1271fn hash_zone_key(node_id: u64, zone: DropZone) -> u64 {
1272    let z = match zone {
1273        DropZone::Center => 1u64,
1274        DropZone::Left => 2,
1275        DropZone::Right => 3,
1276        DropZone::Top => 4,
1277        DropZone::Bottom => 5,
1278        DropZone::Float => 6,
1279    };
1280    node_id ^ (z.wrapping_mul(0x9E3779B97F4A7C15))
1281}
1282
1283fn hash_str_key(prefix: &str, node_id: u64) -> u64 {
1284    let mut h = 1469598103934665603u64;
1285    for b in prefix.as_bytes() {
1286        h ^= *b as u64;
1287        h = h.wrapping_mul(1099511628211u64);
1288    }
1289    h ^ node_id.wrapping_mul(0x9E3779B97F4A7C15)
1290}
1291
1292/// Which edge of the window a [`CollapsibleSidePanel`] is docked to.
1293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1294pub enum DockSide {
1295    Left,
1296    Right,
1297    Top,
1298    Bottom,
1299}
1300
1301/// Anchor captured at the start of an edge drag.
1302#[derive(Clone, Copy, Debug)]
1303struct EdgeDragAnchor {
1304    start_pointer: f32,
1305    start_size: f32,
1306}
1307
1308/// State for a single side-docked, collapsible panel.
1309///
1310/// Wrapped in `Rc<RefCell<_>>` and remembered by the caller. The slide
1311/// animation is advanced inside [`CollapsibleSidePanel`] each frame.
1312pub struct CollapsiblePanelState {
1313    pub side: DockSide,
1314    /// Logical open/closed flag (drives the animation target).
1315    pub open: bool,
1316    /// Animated openness, 0..1 (1 = fully open). Follows the pointer while
1317    /// an edge drag is in progress.
1318    pub open_t: f32,
1319    /// Width (Left/Right) or height (Top/Bottom) of the open panel body, in dp.
1320    pub size_px: f32,
1321    pub min_size_px: f32,
1322    pub max_size_px: f32,
1323    /// Thickness of the draggable separator edge, in dp.
1324    pub separator_px: f32,
1325    drag_anchor: Option<EdgeDragAnchor>,
1326    /// Live body size while dragging (overrides `size_px * open_t`).
1327    drag_size_px: Option<f32>,
1328    last_tick: Option<web_time::Instant>,
1329}
1330
1331impl CollapsiblePanelState {
1332    pub fn new(side: DockSide, size_px: f32) -> Self {
1333        Self {
1334            side,
1335            open: true,
1336            open_t: 1.0,
1337            size_px: size_px.clamp(120.0, 800.0),
1338            min_size_px: 120.0,
1339            max_size_px: 800.0,
1340            separator_px: 4.0,
1341            drag_anchor: None,
1342            drag_size_px: None,
1343            last_tick: None,
1344        }
1345    }
1346
1347    pub fn toggle(&mut self) {
1348        self.open = !self.open;
1349        self.last_tick = None;
1350    }
1351
1352    /// Visible body size (excluding the separator), honoring an active drag.
1353    pub fn visible_body_size(&self) -> f32 {
1354        self.drag_size_px
1355            .unwrap_or(self.size_px * self.open_t)
1356            .max(0.0)
1357    }
1358
1359    /// Total extent of the panel slot, including the separator hit strip.
1360    pub fn visible_total(&self) -> f32 {
1361        let body = self.visible_body_size();
1362        if body > 0.5 {
1363            body + self.separator_px
1364        } else {
1365            // Collapsed: keep a thin hit target for drag-to-reopen.
1366            self.separator_px.max(6.0)
1367        }
1368    }
1369
1370    /// Advance the slide animation toward the logical `open` target.
1371    /// Returns `true` if still animating (caller should `request_frame`).
1372    pub fn advance(&mut self) -> bool {
1373        if self.drag_anchor.is_some() {
1374            return false;
1375        }
1376        let target = if self.open { 1.0 } else { 0.0 };
1377        if (self.open_t - target).abs() < 0.001 {
1378            self.open_t = target;
1379            return false;
1380        }
1381        let now = web_time::Instant::now();
1382        let dt = self
1383            .last_tick
1384            .map(|t| now.duration_since(t).as_secs_f32())
1385            .unwrap_or(0.0);
1386        self.last_tick = Some(now);
1387        let speed = 1.0 / 0.2; // full slide in ~200ms
1388        let dir = (target - self.open_t).signum();
1389        self.open_t = (self.open_t + dir * speed * dt).clamp(0.0, 1.0);
1390        if (self.open_t - target).abs() < 0.001 {
1391            self.open_t = target;
1392            return false;
1393        }
1394        true
1395    }
1396
1397    pub fn begin_edge_drag(&mut self, pointer_along_axis: f32) {
1398        // Anchor from the current *visible* size so dragging a collapsed strip
1399        // back open starts from ~0 rather than jumping to the nominal size_px.
1400        let start_size = self.visible_body_size();
1401        self.drag_anchor = Some(EdgeDragAnchor {
1402            start_pointer: pointer_along_axis,
1403            start_size,
1404        });
1405        self.drag_size_px = Some(start_size);
1406        self.last_tick = None;
1407    }
1408
1409    /// Update the drag from the current pointer position along the panel axis.
1410    pub fn edge_drag(&mut self, pointer_along_axis: f32) {
1411        let Some(a) = self.drag_anchor else {
1412            return;
1413        };
1414        let delta = match self.side {
1415            DockSide::Left | DockSide::Top => pointer_along_axis - a.start_pointer,
1416            DockSide::Right | DockSide::Bottom => a.start_pointer - pointer_along_axis,
1417        };
1418        let size = (a.start_size + delta).clamp(0.0, self.max_size_px * 2.0);
1419        self.drag_size_px = Some(size);
1420        if size < self.min_size_px * 0.5 {
1421            self.open = false;
1422            self.open_t = if self.size_px > 0.0 {
1423                (size / self.size_px).clamp(0.0, 1.0)
1424            } else {
1425                0.0
1426            };
1427        } else {
1428            self.open = true;
1429            self.open_t = 1.0;
1430        }
1431    }
1432
1433    /// End the drag: snap open/closed. A partially-open pull snaps to the
1434    /// nearest state and animates there on subsequent frames.
1435    pub fn end_edge_drag(&mut self) {
1436        let Some(size) = self.drag_size_px.take() else {
1437            self.drag_anchor = None;
1438            return;
1439        };
1440        self.drag_anchor = None;
1441        if self.open && size >= self.min_size_px * 0.5 {
1442            self.size_px = size.clamp(self.min_size_px, self.max_size_px);
1443            self.open_t = 1.0;
1444            self.open = true;
1445        } else {
1446            self.open = false;
1447            self.size_px = size.max(self.min_size_px);
1448        }
1449        self.last_tick = None;
1450    }
1451}
1452
1453/// A side-docked panel with a draggable edge: drag inward to collapse, drag
1454/// the collapsed strip outward to reopen, double-click the edge to toggle.
1455///
1456/// ```ignore
1457/// let left = remember(|| Rc::new(RefCell::new(
1458///     CollapsiblePanelState::new(DockSide::Left, 280.0),
1459/// )));
1460/// Row(Modifier::new().fill_max_size()).child((
1461///     CollapsibleSidePanel("nav", left.clone(), || Column(Modifier::new()).child(Text("Sidebar"))),
1462///     Box(Modifier::new().flex_grow(1.0)).child(main),
1463/// ))
1464/// ```
1465pub fn CollapsibleSidePanel(
1466    key: impl Into<String>,
1467    state: Rc<RefCell<CollapsiblePanelState>>,
1468    content: impl Fn() -> View + 'static,
1469) -> View {
1470    let key = key.into();
1471
1472    // Advance the slide animation; keep frames coming while it is in motion.
1473    {
1474        let mut s = state.borrow_mut();
1475        if s.advance() {
1476            request_frame();
1477        }
1478    }
1479
1480    let th = theme();
1481    let (side, body_size, sep) = {
1482        let s = state.borrow();
1483        (s.side, s.visible_body_size(), s.separator_px.max(4.0))
1484    };
1485    let handle_size = if body_size < 1.0 { sep.max(6.0) } else { sep };
1486    let collapsed = body_size < 1.0;
1487
1488    let handle = {
1489        let state = state.clone();
1490        let last_click = remember_with_key(format!("dock:edge_click:{key}"), || {
1491            RefCell::new(None::<web_time::Instant>)
1492        });
1493
1494        Box(Modifier::new()
1495            .width(match side {
1496                DockSide::Left | DockSide::Right => Dp(Px(handle_size).to_dp().0),
1497                DockSide::Top | DockSide::Bottom => Dp::ZERO,
1498            })
1499            .height(match side {
1500                DockSide::Top | DockSide::Bottom => Dp(Px(handle_size).to_dp().0),
1501                DockSide::Left | DockSide::Right => Dp::ZERO,
1502            })
1503            .fill_max_height()
1504            .fill_max_width()
1505            .background(th.outline.with_alpha(if collapsed { 140 } else { 80 }))
1506            .cursor(match side {
1507                DockSide::Left | DockSide::Right => CursorIcon::EwResize,
1508                DockSide::Top | DockSide::Bottom => CursorIcon::NsResize,
1509            })
1510            .on_pointer_down({
1511                let state = state.clone();
1512                let last_click = last_click.clone();
1513                move |ev| {
1514                    let now = web_time::Instant::now();
1515                    let mut lc = last_click.borrow_mut();
1516                    if let Some(t0) = *lc
1517                        && now.duration_since(t0) < web_time::Duration::from_millis(350)
1518                    {
1519                        state.borrow_mut().toggle();
1520                        *lc = None;
1521                        request_frame();
1522                        return;
1523                    }
1524                    *lc = Some(now);
1525                    let axis = match state.borrow().side {
1526                        DockSide::Left | DockSide::Right => ev.position.x,
1527                        DockSide::Top | DockSide::Bottom => ev.position.y,
1528                    };
1529                    state.borrow_mut().begin_edge_drag(axis);
1530                    request_frame();
1531                }
1532            })
1533            .on_pointer_move({
1534                let state = state.clone();
1535                move |ev| {
1536                    if state.borrow().drag_anchor.is_some() {
1537                        let axis = match state.borrow().side {
1538                            DockSide::Left | DockSide::Right => ev.position.x,
1539                            DockSide::Top | DockSide::Bottom => ev.position.y,
1540                        };
1541                        state.borrow_mut().edge_drag(axis);
1542                        request_frame();
1543                    }
1544                }
1545            })
1546            .on_pointer_up({
1547                let state = state.clone();
1548                move |_ev| {
1549                    state.borrow_mut().end_edge_drag();
1550                    request_frame();
1551                }
1552            }))
1553    };
1554
1555    let body = Box(Modifier::new()
1556        .width(match side {
1557            DockSide::Left | DockSide::Right => Dp(Px(body_size).to_dp().0),
1558            DockSide::Top | DockSide::Bottom => Dp::ZERO,
1559        })
1560        .height(match side {
1561            DockSide::Top | DockSide::Bottom => Dp(Px(body_size).to_dp().0),
1562            DockSide::Left | DockSide::Right => Dp::ZERO,
1563        })
1564        .fill_max_height()
1565        .fill_max_width()
1566        .clip_rounded(Dp(0.0))
1567        .background(th.surface))
1568    .child(if body_size > 0.5 {
1569        Box(Modifier::new().fill_max_size().padding(Dp(8.0))).child(content())
1570    } else {
1571        Box(Modifier::new())
1572    });
1573
1574    match side {
1575        DockSide::Left => Row(Modifier::new().fill_max_height()).child((body, handle)),
1576        DockSide::Right => Row(Modifier::new().fill_max_height()).child((handle, body)),
1577        DockSide::Top => Column(Modifier::new().fill_max_width()).child((body, handle)),
1578        DockSide::Bottom => Column(Modifier::new().fill_max_width()).child((handle, body)),
1579    }
1580}
1581
1582#[cfg(test)]
1583mod tests {
1584    use super::*;
1585
1586    #[test]
1587    fn move_tab_into_center() {
1588        let mut st = DockState::new_with_tabs(vec![1, 2, 3]);
1589        // Create a second tabs node by splitting
1590        assert!(st.dock_panel(1, DropZone::Right, 3));
1591        // Root is now a Split node; docking center into a Split should fail
1592        assert!(!st.dock_panel(st.root.id, DropZone::Center, 2));
1593    }
1594
1595    #[test]
1596    fn remove_collapses_empty_split() {
1597        let mut st = DockState::new_with_tabs(vec![10]);
1598        assert!(st.dock_panel(1, DropZone::Right, 20)); // split created
1599        assert!(st.remove_panel(10));
1600        st.normalize();
1601        // should still not be empty (20 remains)
1602        // root may collapse; ensure at least one tab exists somewhere
1603        fn count_tabs(n: &DockNode) -> usize {
1604            match &n.kind {
1605                DockKind::Tabs { tabs, .. } => tabs.len(),
1606                DockKind::Split { a, b, .. } => count_tabs(a) + count_tabs(b),
1607                DockKind::Empty => 0,
1608            }
1609        }
1610        assert_eq!(count_tabs(&st.root), 1);
1611    }
1612
1613    #[test]
1614    fn drag_to_close_and_reopen() {
1615        let mut p = CollapsiblePanelState::new(DockSide::Left, 200.0);
1616
1617        // Drag the right edge inward to collapse.
1618        p.begin_edge_drag(200.0);
1619        p.edge_drag(20.0);
1620        assert!(!p.open);
1621        p.end_edge_drag();
1622        assert!(!p.open);
1623        assert!(p.open_t < 1.0); // folding shut
1624        while p.advance() {}
1625        assert_eq!(p.open_t, 0.0);
1626        assert_eq!(p.visible_body_size(), 0.0);
1627
1628        // Drag the collapsed strip outward to reopen.
1629        p.begin_edge_drag(0.0);
1630        p.edge_drag(150.0);
1631        assert!(p.open);
1632        assert_eq!(p.visible_body_size(), 150.0);
1633        p.end_edge_drag();
1634        assert!(p.open);
1635        assert_eq!(p.open_t, 1.0);
1636        assert_eq!(p.size_px, 150.0);
1637    }
1638
1639    #[test]
1640    fn advance_animates_toward_target() {
1641        let mut p = CollapsiblePanelState::new(DockSide::Left, 200.0);
1642        p.open_t = 0.0;
1643        assert!(p.advance()); // starts moving toward open
1644        while p.advance() {
1645            // bounded loop: advance() returns false once it settles
1646        }
1647        assert_eq!(p.open_t, 1.0);
1648    }
1649}