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().absolute().offset(
460 Some(Dp(0.0)),
461 Some(Dp(0.0)),
462 Some(Dp(0.0)),
463 Some(Dp(0.0)),
464 ))
465 .child(float_target),
466 Box(Modifier::new().absolute().offset(
467 Some(Dp(0.0)),
468 Some(Dp(0.0)),
469 Some(Dp(0.0)),
470 Some(Dp(0.0)),
471 ))
472 .child(root_view),
473 ))
474}
475
476fn build_registry(panels: Vec<DockPanel>) -> HashMap<PanelId, DockPanel> {
477 let mut m = HashMap::new();
478 for p in panels {
479 m.insert(p.id, p);
480 }
481 m
482}
483
484fn render_node(
485 node: &DockNode,
486 registry: &Rc<HashMap<PanelId, DockPanel>>,
487 dock: &DockHandle,
488 split_hover: &Signal<Option<u64>>,
489 split_drag: &Rc<RefCell<Option<SplitDrag>>>,
490 key_prefix: &str,
491) -> View {
492 match &node.kind {
493 DockKind::Empty => Box(Modifier::new()
494 .fill_max_size()
495 .padding(Dp(6.0))
496 .background(theme().surface_container_lowest)
497 .clip_rounded(theme().shapes.medium)
498 .border(
499 Dp(1.0),
500 theme().outline_variant.with_alpha(80),
501 theme().shapes.medium,
502 )
503 .key(node.id))
504 .child(
505 Box(Modifier::new().fill_max_size().padding(Dp(16.0))).child(
506 Text("Drop panel here")
507 .size(theme().typography.label_medium)
508 .color(theme().on_surface_variant),
509 ),
510 ),
511
512 DockKind::Tabs { tabs, active } => render_tabs(
513 node.id,
514 tabs,
515 *active,
516 registry,
517 dock,
518 split_hover,
519 key_prefix,
520 ),
521
522 DockKind::Split { dir, ratio, a, b } => render_split(
523 node.id,
524 *dir,
525 *ratio,
526 a,
527 b,
528 registry,
529 dock,
530 split_hover,
531 split_drag,
532 key_prefix,
533 ),
534 }
535}
536
537fn render_tabs(
538 node_id: u64,
539 tabs: &[PanelId],
540 active: Option<PanelId>,
541 registry: &Rc<HashMap<PanelId, DockPanel>>,
542 dock: &DockHandle,
543 _split_hover: &Signal<Option<u64>>,
544 key_prefix: &str,
545) -> View {
546 let th = theme();
547
548 const PANEL_PAD: Dp = Dp(5.0);
549 const TAB_BAR_H: Dp = Dp(44.0);
550 const TAB_H: Dp = Dp(32.0);
551 const TAB_RADIUS: Dp = Dp(16.0);
552
553 let active_pid = active.or_else(|| tabs.first().copied());
555
556 let tabbar_rect = remember_with_key(format!("dock:tabbar_rect:{key_prefix}:{node_id}"), || {
557 RefCell::new(Rect::default())
558 });
559
560 let strip_bg = th.surface_container_low;
561 let active_bg = th.secondary_container;
562 let active_fg = th.on_secondary_container;
563 let inactive_fg = th.on_surface_variant;
564 let hover_bg = th.surface_container_high;
565
566 let mut bar_mod = Modifier::new()
567 .fill_max_width()
568 .height(TAB_BAR_H)
569 .background(strip_bg)
570 .padding_values(PaddingValues {
571 left: Dp(8.0),
572 right: Dp(8.0),
573 top: Dp(6.0),
574 bottom: Dp(6.0),
575 })
576 .gap(Dp(6.0))
577 .painter({
578 let tabbar_rect = tabbar_rect.clone();
579 move |_scene, r, _alpha| *tabbar_rect.borrow_mut() = r
580 });
581
582 if dock.drag_active.get() {
583 bar_mod = bar_mod.dock_tab_strip_drop_target(dock, node_id, tabbar_rect.clone());
584 }
585
586 let tab_bar = Row(bar_mod).with_children(
587 tabs.iter()
588 .copied()
589 .filter_map(|pid| {
590 let panel = registry.get(&pid)?;
591 let is_active = Some(pid) == active_pid;
592 let is_hovered = dock.tab_hover.get() == Some(pid);
593
594 let state_set = dock.state.clone();
595 let title = panel.title.clone();
596 let drag_pid = pid;
597
598 let cb_close = dock.callbacks.on_close.clone();
599 let cb_pop = dock.callbacks.on_popout.clone();
600
601 let tab_bg = if is_active {
602 active_bg
603 } else if is_hovered {
604 hover_bg
605 } else {
606 Color::TRANSPARENT
607 };
608
609 let tab_fg = if is_active { active_fg } else { inactive_fg };
610
611 let hover_in = {
612 let tab_hover = dock.tab_hover.clone();
613 move |_| tab_hover.set(Some(pid))
614 };
615
616 let hover_out = {
617 let tab_hover = dock.tab_hover.clone();
618 move |_| {
619 if tab_hover.get() == Some(pid) {
620 tab_hover.set(None);
621 }
622 }
623 };
624
625 let pop_view = if let Some(pop) = cb_pop {
626 let state_for_pop = dock.state.clone();
627 dock_tab_icon_button("↗", tab_fg, move |_| {
628 state_for_pop.borrow_mut().remove_panel(pid);
629 pop(pid);
630 request_frame();
631 })
632 } else {
633 Box(Modifier::new())
634 };
635
636 let close_view = if let Some(close) = cb_close {
637 dock_tab_icon_button("×", tab_fg, move |_| {
638 close(pid);
639 request_frame();
640 })
641 } else {
642 Box(Modifier::new())
643 };
644
645 Some(
646 Row(Modifier::new()
647 .key(pid)
648 .height(TAB_H)
649 .min_width(Dp(108.0))
650 .max_width(Dp(240.0))
651 .clip_rounded(TAB_RADIUS)
652 .background(tab_bg)
653 .padding_values(PaddingValues {
654 left: Dp(12.0),
655 right: Dp(4.0),
656 top: Dp(0.0),
657 bottom: Dp(0.0),
658 })
659 .gap(Dp(4.0))
660 .clickable()
661 .on_pointer_enter(hover_in)
662 .on_pointer_leave(hover_out)
663 .on_pointer_down({
664 let state_set = state_set.clone();
665 move |_| {
666 state_set.borrow_mut().set_active(node_id, pid);
667 request_frame();
668 }
669 })
670 .drag_preview_chip(title.clone(), th.primary)
671 .dock_tab_source(dock, drag_pid))
672 .child((
673 Box(Modifier::new()
674 .height(TAB_H)
675 .weight(1.0)
676 .padding_values(PaddingValues {
677 left: Dp(0.0),
678 right: Dp(4.0),
679 top: Dp(0.0),
680 bottom: Dp(0.0),
681 })
682 .content_alignment(Alignment::Center))
683 .child(
684 Text(title)
685 .size(th.typography.label_large)
686 .single_line()
687 .overflow_ellipsize()
688 .color(tab_fg),
689 ),
690 pop_view,
691 close_view,
692 )),
693 )
694 })
695 .collect::<Vec<_>>(),
696 );
697
698 let content = if let Some(pid) = active_pid {
700 if let Some(panel) = registry.get(&pid) {
701 (panel.content)()
702 } else {
703 Text("Missing panel").color(th.error)
704 }
705 } else {
706 Text("No tabs").color(th.on_surface_variant)
707 };
708
709 let overlay = dock_drop_overlay(node_id, dock, key_prefix);
711
712 ZStack(Modifier::new().fill_max_size().key(node_id)).child((
713 Column(
714 Modifier::new()
715 .fill_max_size()
716 .padding(PANEL_PAD)
717 .clip_rounded(th.shapes.medium)
718 .background(th.surface_container_lowest)
719 .border(Dp(1.0), th.outline_variant.with_alpha(70), th.shapes.medium),
720 )
721 .child((
722 tab_bar,
723 Box(Modifier::new()
724 .fill_max_size()
725 .background(th.surface_container_lowest))
726 .child(Box(Modifier::new().fill_max_size().padding(Dp(8.0))).child(content)),
727 )),
728 Box(Modifier::new()
729 .absolute()
730 .offset(
731 Some(PANEL_PAD),
732 Some(PANEL_PAD + TAB_BAR_H),
733 Some(PANEL_PAD),
734 Some(PANEL_PAD),
735 )
736 .render_z_index(2000.0))
737 .child(overlay),
738 ))
739}
740
741fn dock_tab_icon_button(
742 label: &'static str,
743 fg: Color,
744 on_click: impl Fn(PointerEvent) + 'static,
745) -> View {
746 Box(Modifier::new()
747 .size(Dp(26.0), Dp(26.0))
748 .padding(Dp(2.0))
749 .clip_rounded(Dp(13.0))
750 .background(fg.with_alpha(18))
751 .clickable()
752 .cursor(CursorIcon::Pointer)
753 .on_pointer_down(on_click))
754 .child(
755 Box(Modifier::new()
756 .fill_max_size()
757 .content_alignment(Alignment::Center))
758 .child(Text(label).size(Sp(14.0)).color(fg)),
759 )
760}
761
762fn dock_drop_overlay(node_id: u64, dock: &DockHandle, key_prefix: &str) -> View {
763 let th = theme();
764
765 if !dock.drag_active.get() {
766 return Box(Modifier::new().hit_passthrough());
767 }
768
769 let zone_dp = Dp(72.0);
770 let hover = dock.hover_sig.get();
771
772 let preview = if let Some(h) = hover.as_ref() {
773 if h.node_id == node_id {
774 dock_drop_preview(h.zone)
775 } else {
776 Box(Modifier::new())
777 }
778 } else {
779 Box(Modifier::new())
780 };
781
782 let mk_zone =
783 |zone: DropZone, m: Modifier| -> View { Box(m.dock_drop_zone(dock, node_id, zone)) };
784
785 let left = mk_zone(
788 DropZone::Left,
789 Modifier::new()
790 .absolute()
791 .offset(Some(Dp(0.0)), Some(Dp(0.0)), None, Some(Dp(0.0)))
792 .width(zone_dp),
793 );
794
795 let right = mk_zone(
796 DropZone::Right,
797 Modifier::new()
798 .absolute()
799 .offset(None, Some(Dp(0.0)), Some(Dp(0.0)), Some(Dp(0.0)))
800 .width(zone_dp),
801 );
802
803 let top = mk_zone(
804 DropZone::Top,
805 Modifier::new()
806 .absolute()
807 .offset(Some(zone_dp), Some(Dp(0.0)), Some(zone_dp), None)
808 .height(zone_dp),
809 );
810
811 let bottom = mk_zone(
812 DropZone::Bottom,
813 Modifier::new()
814 .absolute()
815 .offset(Some(zone_dp), None, Some(zone_dp), Some(Dp(0.0)))
816 .height(zone_dp),
817 );
818
819 let center = mk_zone(
820 DropZone::Center,
821 Modifier::new().absolute().offset(
822 Some(zone_dp),
823 Some(zone_dp),
824 Some(zone_dp),
825 Some(zone_dp),
826 ),
827 );
828
829 ZStack(
830 Modifier::new()
831 .fill_max_size()
832 .key(hash_str_key(key_prefix, node_id)),
833 )
834 .child((
835 Box(Modifier::new()
837 .fill_max_size()
838 .background(th.scrim.with_alpha(18))
839 .hit_passthrough()
840 .render_z_index(1000.0)),
841 Box(Modifier::new()
842 .fill_max_size()
843 .hit_passthrough()
844 .render_z_index(2000.0))
845 .child(preview),
846 left,
847 right,
848 top,
849 bottom,
850 center,
851 ))
852}
853
854fn dock_drop_preview(zone: DropZone) -> View {
855 let th = theme();
856
857 let fill = th
858 .primary
859 .with_alpha(38)
860 .composite_over(th.surface_container_lowest);
861 let border = th.primary.with_alpha(210);
862 let radius = th.shapes.large;
863
864 let card = |label: &'static str, modifier: Modifier| -> View {
865 Box(modifier
866 .clip_rounded(radius)
867 .background(fill)
868 .border(Dp(2.0), border, radius))
869 .child(
870 Box(Modifier::new().padding(Dp(12.0))).child(
871 Text(label)
872 .size(th.typography.label_medium)
873 .single_line()
874 .color(th.primary),
875 ),
876 )
877 };
878
879 match zone {
880 DropZone::Center => card(
881 "Add as tab",
882 Modifier::new().absolute().offset(
883 Some(Dp(14.0)),
884 Some(Dp(14.0)),
885 Some(Dp(14.0)),
886 Some(Dp(14.0)),
887 ),
888 ),
889
890 DropZone::Left => Row(Modifier::new()
891 .fill_max_size()
892 .padding(Dp(14.0))
893 .gap(Dp(10.0)))
894 .child((
895 card("Split left", Modifier::new().weight(0.44).fill_max_height()),
896 Box(Modifier::new().weight(0.56)),
897 )),
898
899 DropZone::Right => Row(Modifier::new()
900 .fill_max_size()
901 .padding(Dp(14.0))
902 .gap(Dp(10.0)))
903 .child((
904 Box(Modifier::new().weight(0.56)),
905 card(
906 "Split right",
907 Modifier::new().weight(0.44).fill_max_height(),
908 ),
909 )),
910
911 DropZone::Top => Column(
912 Modifier::new()
913 .fill_max_size()
914 .padding(Dp(14.0))
915 .gap(Dp(10.0)),
916 )
917 .child((
918 card("Split top", Modifier::new().weight(0.44).fill_max_width()),
919 Box(Modifier::new().weight(0.56)),
920 )),
921
922 DropZone::Bottom => Column(
923 Modifier::new()
924 .fill_max_size()
925 .padding(Dp(14.0))
926 .gap(Dp(10.0)),
927 )
928 .child((
929 Box(Modifier::new().weight(0.56)),
930 card(
931 "Split bottom",
932 Modifier::new().weight(0.44).fill_max_width(),
933 ),
934 )),
935
936 DropZone::Float => Box(Modifier::new()),
937 }
938}
939
940fn render_split(
941 node_id: u64,
942 dir: SplitDir,
943 ratio: f32,
944 a: &DockNode,
945 b: &DockNode,
946 registry: &Rc<HashMap<PanelId, DockPanel>>,
947 dock: &DockHandle,
948 split_hover: &Signal<Option<u64>>,
949 split_drag: &Rc<RefCell<Option<SplitDrag>>>,
950 key_prefix: &str,
951) -> View {
952 let th = theme();
953 let ratio = ratio.clamp(0.05, 0.95);
954
955 let rect_rc = remember_with_key(format!("dock:split_rect:{}:{node_id}", key_prefix), || {
957 RefCell::new(Rect::default())
958 });
959
960 let track = {
962 let rect_rc = rect_rc.clone();
963 Modifier::new().painter(move |_scene, r, _alpha| {
964 *rect_rc.borrow_mut() = r;
965 })
966 };
967
968 let divider_thick = Dp(8.0);
969
970 let start_drag = {
971 let split_drag = split_drag.clone();
972 move |_pe: PointerEvent| {
973 *split_drag.borrow_mut() = Some(SplitDrag { node_id });
974 request_frame();
975 }
976 };
977
978 let move_drag = {
979 let split_drag = split_drag.clone();
980 let rect_rc = rect_rc.clone();
981 let state = dock.state.clone();
982 move |pe: PointerEvent| {
983 let Some(sd) = split_drag.borrow().clone() else {
984 return;
985 };
986 if sd.node_id != node_id {
987 return;
988 }
989 let r = *rect_rc.borrow();
990 if r.w <= 1.0 || r.h <= 1.0 {
991 return;
992 }
993 let mut t = match dir {
994 SplitDir::Horizontal => (pe.position_in_window().x - r.x) / r.w,
995 SplitDir::Vertical => (pe.position_in_window().y - r.y) / r.h,
996 };
997 for snap in [0.25_f32, 0.5, 0.75] {
998 if (t - snap).abs() < 0.018 {
999 t = snap;
1000 break;
1001 }
1002 }
1003 state.borrow_mut().set_split_ratio(node_id, t);
1004 request_frame();
1005 }
1006 };
1007
1008 let end_drag = {
1009 let split_drag = split_drag.clone();
1010 move |_pe: PointerEvent| {
1011 *split_drag.borrow_mut() = None;
1013 request_frame();
1014 }
1015 };
1016
1017 let hovered = split_hover.get() == Some(node_id);
1020 let dragging = split_drag
1021 .borrow()
1022 .as_ref()
1023 .map(|sd| sd.node_id == node_id)
1024 .unwrap_or(false);
1025
1026 let active = hovered || dragging;
1027
1028 let gutter_color = if active {
1029 th.primary.with_alpha(24)
1030 } else {
1031 Color::TRANSPARENT
1032 };
1033
1034 let grabber_color = if active {
1035 th.primary
1036 } else {
1037 th.outline_variant.with_alpha(0)
1038 };
1039
1040 let splitter_mod = match dir {
1041 SplitDir::Horizontal => Modifier::new().width(divider_thick).fill_max_height(),
1042 SplitDir::Vertical => Modifier::new().height(divider_thick).fill_max_width(),
1043 };
1044
1045 let grabber = match dir {
1046 SplitDir::Horizontal => Box(Modifier::new()
1047 .fill_max_size()
1048 .content_alignment(Alignment::Center)
1049 .clip_rounded(Dp(4.0)))
1050 .child(Box(Modifier::new()
1051 .width(Dp(4.0))
1052 .offset(None, Some(Dp(24.0)), None, Some(Dp(24.0)))
1053 .background(grabber_color))),
1054 SplitDir::Vertical => Box(Modifier::new()
1055 .fill_max_size()
1056 .content_alignment(Alignment::Center)
1057 .clip_rounded(Dp(4.0)))
1058 .child(Box(Modifier::new()
1059 .height(Dp(4.0))
1060 .offset(Some(Dp(24.0)), None, Some(Dp(24.0)), None)
1061 .background(grabber_color))),
1062 };
1063
1064 let divider = Box(splitter_mod
1065 .background(gutter_color)
1066 .on_pointer_enter({
1067 let split_hover = split_hover.clone();
1068 move |_| {
1069 split_hover.set(Some(node_id));
1070 request_frame();
1071 }
1072 })
1073 .on_pointer_leave({
1074 let split_hover = split_hover.clone();
1075 move |_| {
1076 if split_hover.get() == Some(node_id) {
1077 split_hover.set(None);
1078 request_frame();
1079 }
1080 }
1081 })
1082 .on_pointer_down(start_drag)
1083 .on_pointer_move(move_drag)
1084 .on_pointer_up(end_drag)
1085 .cursor(match dir {
1086 SplitDir::Horizontal => CursorIcon::EwResize,
1087 SplitDir::Vertical => CursorIcon::NsResize,
1088 })
1089 .z_index(1500.0)
1090 .render_z_index(1500.0))
1091 .child(grabber);
1092
1093 let a_view = render_node(a, registry, dock, split_hover, split_drag, key_prefix);
1094 let b_view = render_node(b, registry, dock, split_hover, split_drag, key_prefix);
1095
1096 match dir {
1097 SplitDir::Horizontal => Row(track.fill_max_size().key(node_id)).child((
1098 Box(Modifier::new().weight(ratio)).child(a_view),
1099 divider,
1100 Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
1101 )),
1102 SplitDir::Vertical => Column(track.fill_max_size().key(node_id)).child((
1103 Box(Modifier::new().weight(ratio)).child(a_view),
1104 divider,
1105 Box(Modifier::new().weight(1.0 - ratio)).child(b_view),
1106 )),
1107 }
1108}
1109
1110fn find_node_mut(node: &mut DockNode, id: u64) -> Option<&mut DockNode> {
1111 if node.id == id {
1112 return Some(node);
1113 }
1114 match &mut node.kind {
1115 DockKind::Split { a, b, .. } => find_node_mut(a, id).or_else(|| find_node_mut(b, id)),
1116 _ => None,
1117 }
1118}
1119
1120fn remove_panel_in_node(node: &mut DockNode, pid: PanelId) -> bool {
1121 match &mut node.kind {
1122 DockKind::Empty => false,
1123
1124 DockKind::Tabs { tabs, active } => {
1125 let before = tabs.len();
1126 tabs.retain(|&x| x != pid);
1127 if tabs.len() != before {
1128 if active == &Some(pid) {
1129 *active = tabs.first().copied();
1130 }
1131 if tabs.is_empty() {
1132 node.kind = DockKind::Empty;
1133 }
1134 true
1135 } else {
1136 false
1137 }
1138 }
1139
1140 DockKind::Split { a, b, .. } => {
1141 let ra = remove_panel_in_node(a, pid);
1142 let rb = remove_panel_in_node(b, pid);
1143 ra || rb
1144 }
1145 }
1146}
1147
1148fn normalize_node(node: &mut DockNode) {
1149 match &mut node.kind {
1150 DockKind::Empty => {}
1151 DockKind::Tabs { tabs, active } => {
1152 if tabs.is_empty() {
1153 node.kind = DockKind::Empty;
1154 } else if active.is_none() || !tabs.contains(&active.unwrap()) {
1155 *active = tabs.first().copied();
1156 }
1157 }
1158 DockKind::Split { a, b, ratio, .. } => {
1159 *ratio = ratio.clamp(0.05, 0.95);
1160 normalize_node(a);
1161 normalize_node(b);
1162
1163 let a_empty = matches!(a.kind, DockKind::Empty);
1164 let b_empty = matches!(b.kind, DockKind::Empty);
1165
1166 if a_empty && !b_empty {
1168 node.kind = std::mem::replace(&mut b.kind, DockKind::Empty);
1169 } else if b_empty && !a_empty {
1170 node.kind = std::mem::replace(&mut a.kind, DockKind::Empty);
1171 } else if a_empty && b_empty {
1172 node.kind = DockKind::Empty;
1173 }
1174 }
1175 }
1176}
1177
1178fn hash_zone_key(node_id: u64, zone: DropZone) -> u64 {
1179 let z = match zone {
1180 DropZone::Center => 1u64,
1181 DropZone::Left => 2,
1182 DropZone::Right => 3,
1183 DropZone::Top => 4,
1184 DropZone::Bottom => 5,
1185 DropZone::Float => 6,
1186 };
1187 node_id ^ (z.wrapping_mul(0x9E3779B97F4A7C15))
1188}
1189
1190fn hash_str_key(prefix: &str, node_id: u64) -> u64 {
1191 let mut h = 1469598103934665603u64;
1192 for b in prefix.as_bytes() {
1193 h ^= *b as u64;
1194 h = h.wrapping_mul(1099511628211u64);
1195 }
1196 h ^ node_id.wrapping_mul(0x9E3779B97F4A7C15)
1197}
1198
1199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1201pub enum DockSide {
1202 Left,
1203 Right,
1204 Top,
1205 Bottom,
1206}
1207
1208#[derive(Clone, Copy, Debug)]
1210struct EdgeDragAnchor {
1211 start_pointer: f32,
1212 start_size: f32,
1213}
1214
1215pub struct CollapsiblePanelState {
1220 pub side: DockSide,
1221 pub open: bool,
1223 pub open_t: f32,
1226 pub size_px: f32,
1228 pub min_size_px: f32,
1229 pub max_size_px: f32,
1230 pub separator_px: f32,
1232 drag_anchor: Option<EdgeDragAnchor>,
1233 drag_size_px: Option<f32>,
1235 last_tick: Option<web_time::Instant>,
1236}
1237
1238impl CollapsiblePanelState {
1239 pub fn new(side: DockSide, size_px: f32) -> Self {
1240 Self {
1241 side,
1242 open: true,
1243 open_t: 1.0,
1244 size_px,
1245 min_size_px: 120.0,
1246 max_size_px: 800.0,
1247 separator_px: 4.0,
1248 drag_anchor: None,
1249 drag_size_px: None,
1250 last_tick: None,
1251 }
1252 }
1253
1254 pub fn toggle(&mut self) {
1255 self.open = !self.open;
1256 self.last_tick = None;
1257 }
1258
1259 pub fn visible_body_size(&self) -> f32 {
1261 self.drag_size_px
1262 .unwrap_or(self.size_px * self.open_t)
1263 .max(0.0)
1264 }
1265
1266 pub fn visible_total(&self) -> f32 {
1268 let body = self.visible_body_size();
1269 if body > 0.5 {
1270 body + self.separator_px
1271 } else {
1272 self.separator_px.max(6.0)
1274 }
1275 }
1276
1277 pub fn advance(&mut self) -> bool {
1280 if self.drag_anchor.is_some() {
1281 return false;
1282 }
1283 let target = if self.open { 1.0 } else { 0.0 };
1284 if (self.open_t - target).abs() < 0.001 {
1285 self.open_t = target;
1286 return false;
1287 }
1288 let now = web_time::Instant::now();
1289 let dt = self
1290 .last_tick
1291 .map(|t| now.duration_since(t).as_secs_f32())
1292 .unwrap_or(0.0);
1293 self.last_tick = Some(now);
1294 let speed = 1.0 / 0.2; let dir = (target - self.open_t).signum();
1296 self.open_t = (self.open_t + dir * speed * dt).clamp(0.0, 1.0);
1297 if (self.open_t - target).abs() < 0.001 {
1298 self.open_t = target;
1299 return false;
1300 }
1301 true
1302 }
1303
1304 pub fn begin_edge_drag(&mut self, pointer_along_axis: f32) {
1305 let start_size = self.visible_body_size();
1308 self.drag_anchor = Some(EdgeDragAnchor {
1309 start_pointer: pointer_along_axis,
1310 start_size,
1311 });
1312 self.drag_size_px = Some(start_size);
1313 self.last_tick = None;
1314 }
1315
1316 pub fn edge_drag(&mut self, pointer_along_axis: f32) {
1318 let Some(a) = self.drag_anchor else {
1319 return;
1320 };
1321 let delta = match self.side {
1322 DockSide::Left | DockSide::Top => pointer_along_axis - a.start_pointer,
1323 DockSide::Right | DockSide::Bottom => a.start_pointer - pointer_along_axis,
1324 };
1325 let size = (a.start_size + delta).max(0.0);
1326 self.drag_size_px = Some(size);
1327 if size < self.min_size_px * 0.5 {
1328 self.open = false;
1329 self.open_t = (size / self.size_px).clamp(0.0, 1.0);
1331 } else {
1332 self.open = true;
1333 self.open_t = 1.0;
1334 }
1335 }
1336
1337 pub fn end_edge_drag(&mut self) {
1340 let Some(size) = self.drag_size_px.take() else {
1341 self.drag_anchor = None;
1342 return;
1343 };
1344 self.drag_anchor = None;
1345 if self.open && size >= self.min_size_px * 0.5 {
1346 self.size_px = size.clamp(self.min_size_px, self.max_size_px);
1347 self.open_t = 1.0;
1348 self.open = true;
1349 } else {
1350 self.open = false;
1351 self.size_px = size.max(self.min_size_px);
1352 }
1353 self.last_tick = None;
1354 }
1355}
1356
1357pub fn CollapsibleSidePanel(
1370 key: impl Into<String>,
1371 state: Rc<RefCell<CollapsiblePanelState>>,
1372 content: impl Fn() -> View + 'static,
1373) -> View {
1374 let key = key.into();
1375
1376 {
1378 let mut s = state.borrow_mut();
1379 if s.advance() {
1380 request_frame();
1381 }
1382 }
1383
1384 let th = theme();
1385 let (side, body_size, sep) = {
1386 let s = state.borrow();
1387 (s.side, s.visible_body_size(), s.separator_px.max(4.0))
1388 };
1389 let handle_size = if body_size < 1.0 { sep.max(6.0) } else { sep };
1390 let collapsed = body_size < 1.0;
1391
1392 let handle = {
1393 let state = state.clone();
1394 let last_click = remember_with_key(format!("dock:edge_click:{key}"), || {
1395 RefCell::new(None::<web_time::Instant>)
1396 });
1397
1398 Box(Modifier::new()
1399 .width(match side {
1400 DockSide::Left | DockSide::Right => Dp(Px(handle_size).to_dp().0),
1401 DockSide::Top | DockSide::Bottom => Dp::ZERO,
1402 })
1403 .height(match side {
1404 DockSide::Top | DockSide::Bottom => Dp(Px(handle_size).to_dp().0),
1405 DockSide::Left | DockSide::Right => Dp::ZERO,
1406 })
1407 .fill_max_height()
1408 .fill_max_width()
1409 .background(th.outline.with_alpha(if collapsed { 140 } else { 80 }))
1410 .cursor(match side {
1411 DockSide::Left | DockSide::Right => CursorIcon::EwResize,
1412 DockSide::Top | DockSide::Bottom => CursorIcon::NsResize,
1413 })
1414 .on_pointer_down({
1415 let state = state.clone();
1416 let last_click = last_click.clone();
1417 move |ev| {
1418 let now = web_time::Instant::now();
1419 let mut lc = last_click.borrow_mut();
1420 if let Some(t0) = *lc
1421 && now.duration_since(t0) < web_time::Duration::from_millis(350)
1422 {
1423 state.borrow_mut().toggle();
1424 *lc = None;
1425 request_frame();
1426 return;
1427 }
1428 *lc = Some(now);
1429 let axis = match state.borrow().side {
1430 DockSide::Left | DockSide::Right => ev.position.x,
1431 DockSide::Top | DockSide::Bottom => ev.position.y,
1432 };
1433 state.borrow_mut().begin_edge_drag(axis);
1434 request_frame();
1435 }
1436 })
1437 .on_pointer_move({
1438 let state = state.clone();
1439 move |ev| {
1440 if state.borrow().drag_anchor.is_some() {
1441 let axis = match state.borrow().side {
1442 DockSide::Left | DockSide::Right => ev.position.x,
1443 DockSide::Top | DockSide::Bottom => ev.position.y,
1444 };
1445 state.borrow_mut().edge_drag(axis);
1446 request_frame();
1447 }
1448 }
1449 })
1450 .on_pointer_up({
1451 let state = state.clone();
1452 move |_ev| {
1453 state.borrow_mut().end_edge_drag();
1454 request_frame();
1455 }
1456 }))
1457 };
1458
1459 let body = Box(Modifier::new()
1460 .width(match side {
1461 DockSide::Left | DockSide::Right => Dp(Px(body_size).to_dp().0),
1462 DockSide::Top | DockSide::Bottom => Dp::ZERO,
1463 })
1464 .height(match side {
1465 DockSide::Top | DockSide::Bottom => Dp(Px(body_size).to_dp().0),
1466 DockSide::Left | DockSide::Right => Dp::ZERO,
1467 })
1468 .fill_max_height()
1469 .fill_max_width()
1470 .clip_rounded(Dp(0.0))
1471 .background(th.surface))
1472 .child(if body_size > 0.5 {
1473 Box(Modifier::new().fill_max_size().padding(Dp(8.0))).child(content())
1474 } else {
1475 Box(Modifier::new())
1476 });
1477
1478 match side {
1479 DockSide::Left => Row(Modifier::new().fill_max_height()).child((body, handle)),
1480 DockSide::Right => Row(Modifier::new().fill_max_height()).child((handle, body)),
1481 DockSide::Top => Column(Modifier::new().fill_max_width()).child((body, handle)),
1482 DockSide::Bottom => Column(Modifier::new().fill_max_width()).child((handle, body)),
1483 }
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 use super::*;
1489
1490 #[test]
1491 fn move_tab_into_center() {
1492 let mut st = DockState::new_with_tabs(vec![1, 2, 3]);
1493 assert!(st.dock_panel(1, DropZone::Right, 3));
1495 assert!(!st.dock_panel(st.root.id, DropZone::Center, 2));
1497 }
1498
1499 #[test]
1500 fn remove_collapses_empty_split() {
1501 let mut st = DockState::new_with_tabs(vec![10]);
1502 assert!(st.dock_panel(1, DropZone::Right, 20)); assert!(st.remove_panel(10));
1504 st.normalize();
1505 fn count_tabs(n: &DockNode) -> usize {
1508 match &n.kind {
1509 DockKind::Tabs { tabs, .. } => tabs.len(),
1510 DockKind::Split { a, b, .. } => count_tabs(a) + count_tabs(b),
1511 DockKind::Empty => 0,
1512 }
1513 }
1514 assert_eq!(count_tabs(&st.root), 1);
1515 }
1516
1517 #[test]
1518 fn drag_to_close_and_reopen() {
1519 let mut p = CollapsiblePanelState::new(DockSide::Left, 200.0);
1520
1521 p.begin_edge_drag(200.0);
1523 p.edge_drag(20.0);
1524 assert!(!p.open);
1525 p.end_edge_drag();
1526 assert!(!p.open);
1527 assert!(p.open_t < 1.0); while p.advance() {}
1529 assert_eq!(p.open_t, 0.0);
1530 assert_eq!(p.visible_body_size(), 0.0);
1531
1532 p.begin_edge_drag(0.0);
1534 p.edge_drag(150.0);
1535 assert!(p.open);
1536 assert_eq!(p.visible_body_size(), 150.0);
1537 p.end_edge_drag();
1538 assert!(p.open);
1539 assert_eq!(p.open_t, 1.0);
1540 assert_eq!(p.size_px, 150.0);
1541 }
1542
1543 #[test]
1544 fn advance_animates_toward_target() {
1545 let mut p = CollapsiblePanelState::new(DockSide::Left, 200.0);
1546 p.open_t = 0.0;
1547 assert!(p.advance()); while p.advance() {
1549 }
1551 assert_eq!(p.open_t, 1.0);
1552 }
1553}