1use std::cell::RefCell;
37use std::rc::Rc;
38
39use gpui::{
40 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
41 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
42};
43use gpui_kit_assets::{Icon, icon};
44use gpui_kit_semantics::{NodeSpec, Role, Semantic};
45use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Theme, TypeScale};
46
47use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt};
48use crate::interaction::dnd::{self, DragItem, DropAxis, DropIntent, DropPosition, RowTarget};
49use crate::layout::tree::{SplitChange, SplitLayout, SplitPaneSpec, SplitTree};
50use crate::motion::{Flipping, flip};
51use crate::navigation::tabs::{TabItem, Tabs};
52use crate::overlay::Tooltipped;
53use crate::strings::{ActiveStrings, StringKey};
54
55const RAIL: f32 = 44.0;
58
59const DEFAULT_SHARE: f32 = 0.22;
61
62const BODY_SPLIT: &str = "dock.body";
65const COLUMNS_SPLIT: &str = "dock.columns";
66const ROOT_SPLIT: &str = "dock.root";
67
68type EventHandler = Rc<dyn Fn(DockEvent, &mut Window, &mut App)>;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum DockRegion {
73 Left,
74 Centre,
75 Right,
76 Bottom,
77}
78
79impl DockRegion {
80 pub const ALL: [DockRegion; 4] = [Self::Left, Self::Centre, Self::Right, Self::Bottom];
81
82 pub fn name(self) -> &'static str {
83 match self {
84 Self::Left => "left",
85 Self::Centre => "centre",
86 Self::Right => "right",
87 Self::Bottom => "bottom",
88 }
89 }
90
91 fn upright(self) -> bool {
93 !matches!(self, Self::Bottom)
94 }
95
96 fn index(self) -> usize {
97 match self {
98 Self::Left => 0,
99 Self::Centre => 1,
100 Self::Right => 2,
101 Self::Bottom => 3,
102 }
103 }
104}
105
106pub struct DockPanel {
108 id: SharedString,
109 title: SharedString,
110 icon: Option<Icon>,
111 badge: Option<SharedString>,
112 unavailable: Option<SharedString>,
113 content: RefCell<Option<AnyElement>>,
116}
117
118impl std::fmt::Debug for DockPanel {
119 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 formatter
121 .debug_struct("DockPanel")
122 .field("id", &self.id)
123 .field("title", &self.title)
124 .field("unavailable", &self.unavailable)
125 .finish()
126 }
127}
128
129impl DockPanel {
130 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
131 Self {
132 id: id.into(),
133 title: title.into(),
134 icon: None,
135 badge: None,
136 unavailable: None,
137 content: RefCell::new(None),
138 }
139 }
140
141 pub fn icon(mut self, glyph: Icon) -> Self {
142 self.icon = Some(glyph);
143 self
144 }
145
146 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
148 self.badge = Some(badge.into());
149 self
150 }
151
152 pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
158 self.unavailable = Some(reason.into());
159 self
160 }
161
162 pub fn content(self, content: impl IntoElement) -> Self {
163 *self.content.borrow_mut() = Some(content.into_any_element());
164 self
165 }
166
167 pub fn id(&self) -> &SharedString {
168 &self.id
169 }
170
171 pub fn title(&self) -> &SharedString {
172 &self.title
173 }
174
175 pub fn is_unavailable(&self) -> bool {
176 self.unavailable.is_some()
177 }
178}
179
180#[derive(Debug, Clone, PartialEq)]
182pub enum DockEvent {
183 PanelSelected {
185 region: DockRegion,
186 panel: SharedString,
187 },
188 PanelMoved {
190 panel: SharedString,
191 to_region: DockRegion,
192 before: Option<SharedString>,
196 },
197 RegionCollapsed { region: DockRegion, collapsed: bool },
199 RegionResized { region: DockRegion, ratio: f32 },
202}
203
204#[derive(Default)]
205struct Region {
206 panels: Vec<DockPanel>,
207 active: Option<SharedString>,
208 collapsed: bool,
209 share: Option<f32>,
210 min: Option<f32>,
211}
212
213impl Region {
214 fn active_panel(&self) -> Option<&DockPanel> {
215 match &self.active {
216 Some(id) => self.panels.iter().find(|panel| &panel.id == id),
217 None => self.panels.first(),
218 }
219 }
220
221 fn share(&self) -> f32 {
222 self.share.unwrap_or(DEFAULT_SHARE).clamp(0.0, 1.0)
223 }
224}
225
226#[derive(IntoElement)]
228pub struct Dock {
229 ident: Ident,
230 regions: [Region; 4],
231 disabled: bool,
232 on_event: Option<EventHandler>,
233}
234
235impl std::fmt::Debug for Dock {
236 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 formatter
238 .debug_struct("Dock")
239 .field("ident", &self.ident)
240 .field(
241 "panels",
242 &self
243 .regions
244 .iter()
245 .map(|region| region.panels.len())
246 .collect::<Vec<_>>(),
247 )
248 .field("disabled", &self.disabled)
249 .field("has_handler", &self.on_event.is_some())
250 .finish()
251 }
252}
253
254impl Dock {
255 pub fn new(ident: impl Into<Ident>) -> Self {
256 Self {
257 ident: ident.into(),
258 regions: Default::default(),
259 disabled: false,
260 on_event: None,
261 }
262 }
263
264 pub fn panel(mut self, region: DockRegion, panel: DockPanel) -> Self {
265 self.regions[region.index()].panels.push(panel);
266 self
267 }
268
269 pub fn panels(
270 mut self,
271 region: DockRegion,
272 panels: impl IntoIterator<Item = DockPanel>,
273 ) -> Self {
274 self.regions[region.index()].panels.extend(panels);
275 self
276 }
277
278 pub fn active(mut self, region: DockRegion, panel: impl Into<SharedString>) -> Self {
280 self.regions[region.index()].active = Some(panel.into());
281 self
282 }
283
284 pub fn collapsed(mut self, region: DockRegion, collapsed: bool) -> Self {
286 self.regions[region.index()].collapsed = collapsed;
287 self
288 }
289
290 pub fn share(mut self, region: DockRegion, share: f32) -> Self {
292 self.regions[region.index()].share = Some(share);
293 self
294 }
295
296 pub fn min_size(mut self, region: DockRegion, min: f32) -> Self {
298 self.regions[region.index()].min = Some(min);
299 self
300 }
301
302 pub fn on_event(
303 mut self,
304 handler: impl Fn(DockEvent, &mut Window, &mut App) + 'static,
305 ) -> Self {
306 self.on_event = Some(Rc::new(handler));
307 self
308 }
309
310 fn region(&self, region: DockRegion) -> &Region {
311 &self.regions[region.index()]
312 }
313
314 fn occupied(&self, region: DockRegion) -> bool {
315 !self.region(region).panels.is_empty()
316 }
317
318 fn leaf(&self, region: DockRegion) -> SplitLayout {
324 let state = self.region(region);
325 let min = state.min.unwrap_or(RAIL * 4.0);
326 let spec = SplitPaneSpec::new(region.name())
327 .rail(RAIL)
328 .collapsed(state.collapsed);
329 SplitLayout::leaf(match region {
330 DockRegion::Left | DockRegion::Right => spec.min_width(min),
331 DockRegion::Bottom => spec.min_height(min),
332 DockRegion::Centre => spec.min(min),
334 })
335 }
336
337 fn layout(&self) -> SplitLayout {
343 let mut tree = self.leaf(DockRegion::Centre);
344 if self.occupied(DockRegion::Right) {
345 tree = SplitLayout::horizontal(
346 COLUMNS_SPLIT,
347 1.0 - self.region(DockRegion::Right).share(),
348 tree,
349 self.leaf(DockRegion::Right),
350 );
351 }
352 if self.occupied(DockRegion::Left) {
353 tree = SplitLayout::horizontal(
354 BODY_SPLIT,
355 self.region(DockRegion::Left).share(),
356 self.leaf(DockRegion::Left),
357 tree,
358 );
359 }
360 if self.occupied(DockRegion::Bottom) {
361 tree = SplitLayout::vertical(
362 ROOT_SPLIT,
363 1.0 - self.region(DockRegion::Bottom).share(),
364 tree,
365 self.leaf(DockRegion::Bottom),
366 );
367 }
368 tree
369 }
370
371 fn surface(&self, region: DockRegion) -> SharedString {
372 self.ident.child(region.name()).child("tabs").semantic_id()
373 }
374
375 fn owns_prefix(&self) -> String {
378 format!("{}.", self.ident.as_str())
379 }
380
381 fn header(
382 &self,
383 region: DockRegion,
384 theme: &Theme,
385 window: &mut Window,
386 cx: &mut App,
387 ) -> AnyElement {
388 let state = self.region(region);
389 let ident = self.ident.child(region.name());
390 let active = state.active_panel().map(|panel| panel.id.clone());
391
392 let mut tabs = Tabs::new(ident.child("tabs"))
393 .small()
394 .tabs(state.panels.iter().map(|panel| {
395 let mut tab = TabItem::new(panel.id.clone(), panel.title.clone());
396 if let Some(glyph) = panel.icon {
397 tab = tab.icon(glyph);
398 }
399 if let Some(badge) = panel.badge.clone() {
400 tab = tab.badge(badge);
401 }
402 tab
403 }))
404 .disabled(self.disabled);
405 if let Some(active) = active {
406 tabs = tabs.selected(active);
407 }
408
409 if let (false, Some(handler)) = (self.disabled, self.on_event.clone()) {
410 let selected = Rc::clone(&handler);
411 let prefix = self.owns_prefix();
412 let panels: Vec<SharedString> =
413 state.panels.iter().map(|panel| panel.id.clone()).collect();
414 tabs = tabs
415 .on_select(move |panel, window, cx| {
416 selected(DockEvent::PanelSelected { region, panel }, window, cx);
417 })
418 .reorderable(true)
419 .accepts(move |item: &DragItem, _: &DropPosition| item.source.starts_with(&prefix))
420 .on_reorder(move |intent, window, cx| {
421 handler(
422 DockEvent::PanelMoved {
423 panel: intent.item.id.clone(),
424 to_region: region,
425 before: before_in(&panels, &intent.position),
426 },
427 window,
428 cx,
429 );
430 });
431 }
432
433 let collapse = self
434 .on_event
435 .clone()
436 .filter(|_| !self.disabled)
437 .map(|handler| {
438 let button = ident.child("collapse");
439 let name = cx.strings().text(StringKey::DockCollapseRegion);
440 div()
441 .id(button.element_id())
442 .flex_none()
443 .flex()
444 .items_center()
445 .justify_center()
446 .mb(px(theme.borders.thick))
447 .size(px(theme.control.get(ControlSize::Sm).height))
448 .radius(theme, Radius::Control)
449 .cursor_pointer()
450 .tab_index(0)
451 .pressable(cx)
452 .hover(|style| style.bg(theme.colors.hover))
453 .focus_ring(theme)
454 .child(
455 icon(Icon::Sidebar)
456 .size(px(theme.control.get(ControlSize::Sm).icon_size))
457 .text_color(theme.colors.text_muted),
458 )
459 .on_click(move |_, window, cx| {
460 handler(
461 DockEvent::RegionCollapsed {
462 region,
463 collapsed: true,
464 },
465 window,
466 cx,
467 );
468 })
469 .tip(button.clone(), name.clone())
470 .semantic_in(
471 cx,
472 NodeSpec::new(button.semantic_id(), Role::Button)
473 .parent(ident.semantic_id())
474 .text(name),
475 )
476 });
477
478 let _ = window;
479 div()
480 .row()
481 .w_full()
482 .flex_none()
483 .items_end()
484 .gap_token(theme, Space::Xs)
485 .px_token(theme, Space::Xs)
486 .bg(theme.colors.panel)
487 .child(div().flex_1().min_w(px(0.0)).overflow_hidden().child(tabs))
488 .children(collapse)
489 .into_any_element()
490 }
491
492 fn body(
494 &self,
495 region: DockRegion,
496 theme: &Theme,
497 window: &mut Window,
498 cx: &mut App,
499 ) -> AnyElement {
500 let Some(panel) = self.region(region).active_panel() else {
501 return div().flex_1().into_any_element();
502 };
503 let ident = self.ident.child(region.name()).child(panel.id.as_ref());
504
505 let content = match &panel.unavailable {
506 Some(reason) => div()
508 .column()
509 .flex_1()
510 .items_center()
511 .justify_center()
512 .gap_token(theme, Space::Sm)
513 .p_token(theme, Space::Lg)
514 .child(
515 icon(Icon::CloseCircle)
516 .size(px(20.0))
517 .text_color(theme.colors.warning),
518 )
519 .child(
520 div()
521 .type_scale(theme, TypeScale::Body)
522 .text_color(theme.colors.text)
523 .child(panel.title.clone()),
524 )
525 .child(
526 div()
527 .max_w(px(320.0))
528 .text_align(gpui::TextAlign::Center)
529 .type_scale(theme, TypeScale::Caption)
530 .text_color(theme.colors.text_muted)
531 .child(reason.clone()),
532 )
533 .into_any_element(),
534 None => div()
535 .flex_1()
536 .min_h(px(0.0))
537 .overflow_hidden()
538 .children(panel.content.borrow_mut().take())
539 .into_any_element(),
540 };
541
542 let mut frame = div()
543 .id(ident.element_id())
544 .column()
545 .flex_1()
546 .min_h(px(0.0))
547 .overflow_hidden()
548 .child(content);
549
550 if let (false, Some(handler)) = (self.disabled, self.on_event.clone()) {
551 let surface = self.surface(region);
552 let name = SharedString::from(region.name());
553 let landing =
554 dnd::surface_drag(&surface, window, cx).and_then(|drag| drag.indicator_for(&name));
555 frame = frame.children(landing.map(|(position, accepted)| {
556 dnd::indicator(&position, accepted, DropAxis::Vertical, cx)
557 }));
558 let prefix = self.owns_prefix();
559 frame = dnd::drop_target(
560 frame,
561 RowTarget {
562 surface,
563 id: name,
564 index: 0,
565 allow_into: true,
566 axis: DropAxis::Vertical,
567 accepts: Rc::new(move |item: &DragItem, _: &DropPosition| {
568 item.source.starts_with(&prefix)
569 }),
570 on_drop: Rc::new(move |intent: &DropIntent, window, cx| {
571 handler(
572 DockEvent::PanelMoved {
573 panel: intent.item.id.clone(),
574 to_region: region,
575 before: None,
576 },
577 window,
578 cx,
579 );
580 }),
581 },
582 );
583 }
584
585 let mut spec = NodeSpec::new(ident.semantic_id(), Role::TabPanel)
586 .parent(self.ident.child(region.name()).semantic_id())
587 .text(panel.title.clone())
588 .invalid(panel.unavailable.is_some());
589 if let Some(reason) = panel.unavailable.clone() {
590 spec = spec.value(reason);
591 }
592
593 let slide = flip(self.ident.child(panel.id.as_ref()).semantic_id(), cx);
596 frame
597 .semantic_in(cx, spec)
598 .flip(&slide, window, cx)
599 .into_any_element()
600 }
601
602 fn rail(&self, region: DockRegion, theme: &Theme, cx: &mut App) -> AnyElement {
608 let state = self.region(region);
609 let ident = self.ident.child(region.name()).child("rail");
610 let metrics = theme.control.get(ControlSize::Sm);
611 let active = state.active_panel().map(|panel| panel.id.clone());
612 let actionable = !self.disabled && self.on_event.is_some();
613
614 let items: Vec<_> = state
615 .panels
616 .iter()
617 .map(|panel| {
618 let item = ident.child(panel.id.as_ref());
619 let current = active.as_ref() == Some(&panel.id);
620 let color = if current {
621 theme.colors.text
622 } else {
623 theme.colors.text_muted
624 };
625 let mut glyph = div()
626 .id(item.element_id())
627 .flex()
628 .flex_none()
629 .items_center()
630 .justify_center()
631 .size(px(metrics.height))
632 .radius(theme, Radius::Control)
633 .when(current, |element| element.bg(theme.colors.selected))
634 .child(match panel.icon {
635 Some(glyph) => icon(glyph)
636 .size(px(metrics.icon_size))
637 .text_color(color)
638 .into_any_element(),
639 None => div()
640 .type_scale(theme, TypeScale::Caption)
641 .text_color(color)
642 .child(initial(&panel.title))
643 .into_any_element(),
644 })
645 .when(actionable, |element| {
646 element
647 .cursor_pointer()
648 .tab_index(0)
649 .pressable(cx)
650 .hover(|style| style.bg(theme.colors.hover))
651 .focus_ring(theme)
652 })
653 .tip(item.clone(), panel.title.clone());
654
655 if let (true, Some(handler)) = (actionable, self.on_event.clone()) {
656 let id = panel.id.clone();
657 glyph = glyph.on_click(move |_, window, cx| {
658 handler(
662 DockEvent::PanelSelected {
663 region,
664 panel: id.clone(),
665 },
666 window,
667 cx,
668 );
669 handler(
670 DockEvent::RegionCollapsed {
671 region,
672 collapsed: false,
673 },
674 window,
675 cx,
676 );
677 });
678 }
679
680 glyph.semantic_in(
681 cx,
682 NodeSpec::new(item.semantic_id(), Role::Button)
683 .parent(ident.semantic_id())
684 .selected(current)
685 .disabled(!actionable)
686 .text(panel.title.clone()),
687 )
688 })
689 .collect();
690
691 let upright = region.upright();
692 div()
693 .flex()
694 .when(upright, |rail| rail.flex_col().size_full())
695 .when(!upright, |rail| rail.flex_row().size_full())
696 .items_center()
697 .gap_token(theme, Space::Xs)
698 .p_token(theme, Space::Xs)
699 .bg(theme.colors.panel)
700 .children(items)
701 .semantic_in(
702 cx,
703 NodeSpec::new(ident.semantic_id(), Role::List)
704 .parent(self.ident.child(region.name()).semantic_id())
705 .expanded(false)
706 .value(state.panels.len().to_string()),
707 )
708 .into_any_element()
709 }
710
711 fn region_element(
712 &self,
713 region: DockRegion,
714 theme: &Theme,
715 window: &mut Window,
716 cx: &mut App,
717 ) -> AnyElement {
718 let state = self.region(region);
719 let ident = self.ident.child(region.name());
720 let inner = if state.collapsed {
721 self.rail(region, theme, cx)
722 } else {
723 div()
724 .column()
725 .size_full()
726 .overflow_hidden()
727 .child(self.header(region, theme, window, cx))
728 .child(self.body(region, theme, window, cx))
729 .into_any_element()
730 };
731
732 div()
733 .column()
734 .size_full()
735 .overflow_hidden()
736 .bg(if region == DockRegion::Centre {
737 theme.colors.canvas
738 } else {
739 theme.colors.panel
740 })
741 .child(inner)
742 .semantic_in(
743 cx,
744 NodeSpec::new(ident.semantic_id(), Role::Region)
745 .parent(self.ident.semantic_id())
746 .expanded(!state.collapsed)
747 .value(state.panels.len().to_string()),
748 )
749 .into_any_element()
750 }
751}
752
753fn before_in(panels: &[SharedString], position: &DropPosition) -> Option<SharedString> {
755 let anchor = position.anchor();
756 match position {
757 DropPosition::Before(_) => Some(anchor.clone()),
758 DropPosition::After(_) => panels
759 .iter()
760 .position(|id| id == anchor)
761 .and_then(|at| panels.get(at + 1))
762 .cloned(),
763 DropPosition::Into(_) => None,
765 }
766}
767
768fn region_change(change: &SplitChange) -> Option<DockEvent> {
770 match change {
771 SplitChange::Ratio { split, ratio } => {
772 let (region, share) = match split.as_ref() {
773 BODY_SPLIT => (DockRegion::Left, *ratio),
774 COLUMNS_SPLIT => (DockRegion::Right, 1.0 - *ratio),
775 ROOT_SPLIT => (DockRegion::Bottom, 1.0 - *ratio),
776 _ => return None,
777 };
778 Some(DockEvent::RegionResized {
779 region,
780 ratio: share,
781 })
782 }
783 SplitChange::Collapsed { pane, .. } => DockRegion::ALL
784 .into_iter()
785 .find(|region| region.name() == pane.as_ref())
786 .map(|region| DockEvent::RegionCollapsed {
787 region,
788 collapsed: true,
789 }),
790 }
791}
792
793fn initial(title: &SharedString) -> SharedString {
794 SharedString::from(
795 title
796 .chars()
797 .next()
798 .map(|first| first.to_uppercase().to_string())
799 .unwrap_or_default(),
800 )
801}
802
803impl Disableable for Dock {
804 fn disabled(mut self, disabled: bool) -> Self {
806 self.disabled = disabled;
807 self
808 }
809}
810
811impl RenderOnce for Dock {
812 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
813 let theme = cx.theme().clone();
814 let mut tree = SplitTree::new(self.ident.child("layout")).layout(self.layout());
815
816 for region in DockRegion::ALL {
817 if self.occupied(region) {
818 tree = tree.pane(
819 region.name(),
820 self.region_element(region, &theme, window, cx),
821 );
822 }
823 }
824
825 if let (false, Some(handler)) = (self.disabled, self.on_event.clone()) {
826 tree = tree.on_change(move |change, window, cx| {
827 if let Some(event) = region_change(&change) {
828 handler(event, window, cx);
829 }
830 });
831 }
832
833 div()
834 .id(self.ident.element_id())
835 .size_full()
836 .overflow_hidden()
837 .bg(theme.colors.canvas)
838 .child(tree)
839 .semantic_in(
840 cx,
841 NodeSpec::new(self.ident.semantic_id(), Role::Group).value(
842 self.regions
843 .iter()
844 .map(|region| region.panels.len())
845 .sum::<usize>()
846 .to_string(),
847 ),
848 )
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use crate::layout::SplitSide;
856
857 #[test]
858 fn a_region_with_no_panels_is_left_out_of_the_tree() {
859 let dock = Dock::new("dock").panel(DockRegion::Centre, DockPanel::new("editor", "Editor"));
860 let layout = dock.layout();
861 let names: Vec<&str> = layout
862 .panes()
863 .iter()
864 .map(|pane| pane.id().as_ref())
865 .collect();
866 assert_eq!(names, vec!["centre"]);
867 }
868
869 #[test]
870 fn every_occupied_region_becomes_a_leaf() {
871 let dock = Dock::new("dock")
872 .panel(DockRegion::Left, DockPanel::new("files", "Files"))
873 .panel(DockRegion::Centre, DockPanel::new("editor", "Editor"))
874 .panel(DockRegion::Right, DockPanel::new("outline", "Outline"))
875 .panel(DockRegion::Bottom, DockPanel::new("terminal", "Terminal"));
876 let layout = dock.layout();
877 let names: Vec<&str> = layout
878 .panes()
879 .iter()
880 .map(|pane| pane.id().as_ref())
881 .collect();
882 assert_eq!(names, vec!["left", "centre", "right", "bottom"]);
883 }
884
885 #[test]
886 fn a_ratio_reads_back_as_the_share_the_region_asked_for() {
887 assert_eq!(
888 region_change(&SplitChange::Ratio {
889 split: COLUMNS_SPLIT.into(),
890 ratio: 0.7,
891 }),
892 Some(DockEvent::RegionResized {
893 region: DockRegion::Right,
894 ratio: 0.3
895 })
896 );
897 assert_eq!(
898 region_change(&SplitChange::Ratio {
899 split: BODY_SPLIT.into(),
900 ratio: 0.3,
901 }),
902 Some(DockEvent::RegionResized {
903 region: DockRegion::Left,
904 ratio: 0.3
905 })
906 );
907 }
908
909 #[test]
910 fn a_drop_after_the_last_tab_names_no_neighbour() {
911 let panels: Vec<SharedString> = vec!["files".into(), "search".into()];
912 assert_eq!(
913 before_in(&panels, &DropPosition::Before("search".into())),
914 Some(SharedString::from("search"))
915 );
916 assert_eq!(
917 before_in(&panels, &DropPosition::After("files".into())),
918 Some(SharedString::from("search"))
919 );
920 assert_eq!(
921 before_in(&panels, &DropPosition::After("search".into())),
922 None
923 );
924 assert_eq!(before_in(&panels, &DropPosition::Into("left".into())), None);
925 }
926
927 #[test]
928 fn a_payload_from_somewhere_else_is_not_a_panel() {
929 let prefix = Dock::new("dock").owns_prefix();
930 assert!(
931 DragItem::new("dock.left.tabs", "files", "Files")
932 .source
933 .starts_with(&prefix)
934 );
935 assert!(
936 !DragItem::new("queue", "step-build", "Build")
937 .source
938 .starts_with(&prefix)
939 );
940 }
941
942 #[test]
943 fn collapsing_names_the_region_the_divider_stood_beside() {
944 assert_eq!(
945 region_change(&SplitChange::Collapsed {
946 split: BODY_SPLIT.into(),
947 side: SplitSide::Start,
948 pane: "left".into(),
949 }),
950 Some(DockEvent::RegionCollapsed {
951 region: DockRegion::Left,
952 collapsed: true
953 })
954 );
955 }
956}