Skip to main content

repose_docking/
lib.rs

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