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