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