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