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