Skip to main content

repose_docking/
lib.rs

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