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