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