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 icon: Option<char>,
21 pub content: Rc<dyn Fn() -> View>,
22}
23
24#[derive(Clone, Default)]
25pub struct DockCallbacks {
26 pub on_popout: Option<Rc<dyn Fn(PanelId)>>,
29
30 pub on_close: Option<Rc<dyn Fn(PanelId)>>,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum SplitDir {
36 Horizontal, Vertical, }
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#[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, 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 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 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 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 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 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#[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
321pub 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
343pub trait DockModifierExt: Sized {
350 fn dock_tab_source(self, dock: &DockHandle, panel_id: PanelId) -> Modifier;
352
353 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 fn dock_drop_zone(self, dock: &DockHandle, node_id: u64, zone: DropZone) -> Modifier;
363
364 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 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 let float_target = Box(Modifier::new()
518 .fill_max_size()
519 .z_index(-1000.0)
520 .dock_float_target(&dock));
521
522 let root_view = {
524 let root = dock.state.borrow().root.clone();
525 render_node(
526 &root,
527 ®istry,
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(¤t.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 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 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 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 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 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 let rect_rc = remember_with_key(format!("dock:split_rect:{}:{node_id}", key_prefix), || {
1052 RefCell::new(Rect::default())
1053 });
1054
1055 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 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
1197fn 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1325pub enum DockSide {
1326 Left,
1327 Right,
1328 Top,
1329 Bottom,
1330}
1331
1332#[derive(Clone, Copy, Debug)]
1334struct EdgeDragAnchor {
1335 start_pointer: f32,
1336 start_size: f32,
1337}
1338
1339pub struct CollapsiblePanelState {
1344 pub side: DockSide,
1345 pub open: bool,
1347 pub open_t: f32,
1350 pub size_px: f32,
1352 pub min_size_px: f32,
1353 pub max_size_px: f32,
1354 pub separator_px: f32,
1356 drag_anchor: Option<EdgeDragAnchor>,
1357 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 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 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 self.separator_px.max(6.0)
1398 }
1399 }
1400
1401 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; 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 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 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 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
1487pub 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 {
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 assert!(st.dock_panel(1, DropZone::Right, 3));
1630 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)); assert!(st.remove_panel(10));
1639 st.normalize();
1640 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 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); while p.advance() {}
1664 assert_eq!(p.open_t, 0.0);
1665 assert_eq!(p.visible_body_size(), 0.0);
1666
1667 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()); while p.advance() {
1684 }
1686 assert_eq!(p.open_t, 1.0);
1687 }
1688}