use gpui::{ AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_layout::dock::{Dock, DockState, PanelDescriptor, SplitDirection}; use gpui_native_theme::Theme; struct Panel { tag: &'static str, } impl Render for Panel { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .size_full() .debug_selector(move || format!("panel-{}", self.tag)) } } fn descriptor(tag: &'static str) -> PanelDescriptor { PanelDescriptor::new(tag, tag, move |_, cx| cx.new(|_| Panel { tag }).into()) } struct Harness { dock: Entity, } impl Render for Harness { fn render( &mut self, _window: &mut Window, _cx: &mut gpui::Context, ) -> impl gpui::IntoElement { div() .size_full() .child(Dock::new("test-dock", self.dock.clone())) } } fn dock_window(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { cx.update(Theme::init); let dock = cx.update(DockState::new); let dock_for_window = dock.clone(); let window = cx.add_window(move |_, _| Harness { dock: dock_for_window, }); let mut visual = VisualTestContext::from_window(window.into(), cx); redraw(&mut visual); (dock, visual) } fn redraw(visual: &mut VisualTestContext) { visual.update(|window, cx| { window.draw(cx).clear(cx); }); } fn bounds(visual: &mut VisualTestContext, selector: &str) -> Option> { visual.debug_bounds(Box::leak(selector.to_string().into_boxed_str())) } fn center_of(bounds: gpui::Bounds) -> gpui::Point { point(bounds.center().x, bounds.center().y) } fn press(visual: &mut VisualTestContext, at: gpui::Point, click_count: usize) { visual.simulate_event(MouseDownEvent { position: at, click_count, modifiers: Modifiers::default(), button: MouseButton::Left, first_mouse: false, }); visual.simulate_event(MouseUpEvent { position: at, click_count, modifiers: Modifiers::default(), button: MouseButton::Left, }); } fn drag(visual: &mut VisualTestContext, from: gpui::Point, to: gpui::Point) { visual.simulate_mouse_down(from, MouseButton::Left, Modifiers::default()); visual.simulate_event(MouseMoveEvent { position: to, pressed_button: Some(MouseButton::Left), modifiers: Modifiers::default(), }); visual.simulate_mouse_up(to, MouseButton::Left, Modifiers::default()); } fn register(dock: &Entity, tags: &[&'static str], cx: &mut TestAppContext) { cx.update(|cx| { dock.update(cx, |dock, cx| { for tag in tags { dock.register_panel(descriptor(tag), cx); } }); }); } fn open(visual: &mut VisualTestContext, dock: &Entity, tag: &'static str) { visual.update(|window, cx| { dock.update(cx, |dock, cx| dock.open_panel(tag, window, cx)); }); redraw(visual); } #[test] fn opening_a_panel_adds_its_tab_and_activates_it() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); assert!( bounds(&mut visual, "panel-inbox").is_some(), "opened panel missing" ); assert!( bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some(), "tab missing" ); open(&mut visual, &dock, "explore"); assert!(bounds(&mut visual, "panel-explore").is_some()); assert!( bounds(&mut visual, "panel-inbox").is_none(), "the inactive view must not render" ); assert!( bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some(), "the strip keeps both tabs" ); assert!(dock.read_with(&cx, |dock, _| dock.is_open("inbox"))); assert!(dock.read_with(&cx, |dock, _| dock.is_open("explore"))); } #[test] fn clicking_a_tab_activates_its_panel() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); let tab = bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").expect("inbox tab missing"); press(&mut visual, center_of(tab), 1); redraw(&mut visual); assert!(bounds(&mut visual, "panel-inbox").is_some()); assert!(bounds(&mut visual, "panel-explore").is_none()); } #[test] fn the_tab_close_control_closes_its_panel() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); let close = bounds(&mut visual, "test-dock-pane-0-tabs-close-inbox").expect("close missing"); press(&mut visual, center_of(close), 1); redraw(&mut visual); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_none()); assert!(bounds(&mut visual, "panel-explore").is_some()); } #[test] fn closing_the_active_tab_activates_the_neighbor() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); cx.update(|cx| { dock.update(cx, |dock, cx| dock.close_panel("explore", cx)); }); redraw(&mut visual); assert!( bounds(&mut visual, "panel-inbox").is_some(), "the neighbor tab should activate" ); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").is_none()); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); } #[test] fn splitting_moves_the_tab_and_closing_collapses() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); cx.update(|cx| { dock.update(cx, |dock, cx| { dock.split_panel("explore", SplitDirection::Right, cx) }); }); redraw(&mut visual); assert!( bounds(&mut visual, "resize-handle").is_some(), "the split renders a seam" ); assert!(bounds(&mut visual, "test-dock-pane-0").is_some()); assert!(bounds(&mut visual, "test-dock-pane-1").is_some()); assert!( bounds(&mut visual, "panel-explore").is_some(), "the moved tab activates in its new pane" ); // Closing the new pane's only tab collapses its split. cx.update(|cx| { dock.update(cx, |dock, cx| dock.close_panel("explore", cx)); }); redraw(&mut visual); assert!(bounds(&mut visual, "resize-handle").is_none()); assert!(bounds(&mut visual, "test-dock-pane-1").is_none()); assert!(bounds(&mut visual, "test-dock-pane-0").is_some()); assert!(bounds(&mut visual, "panel-inbox").is_some()); } #[test] fn splitting_at_a_target_pane_places_the_new_pane_there() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); cx.update(|cx| { dock.update(cx, |dock, cx| { dock.split_panel("explore", SplitDirection::Right, cx) }); }); redraw(&mut visual); // Move inbox into a new pane left of explore's pane (pane 1), not beside // inbox's own (pane 0, which empties and collapses). cx.update(|cx| { dock.update(cx, |dock, cx| { dock.split_panel_at("inbox", "explore", SplitDirection::Left, cx) }); }); redraw(&mut visual); let moved = bounds(&mut visual, "test-dock-pane-3").expect("the new pane missing"); let target = bounds(&mut visual, "test-dock-pane-1").expect("target pane missing"); assert!( moved.right() <= target.left(), "the new pane must sit left of the anchored pane" ); assert!(bounds(&mut visual, "test-dock-pane-0").is_none()); } #[test] fn dragging_the_seam_resizes_and_double_click_resets() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); cx.update(|cx| { dock.update(cx, |dock, cx| { dock.split_panel("explore", SplitDirection::Right, cx) }); }); redraw(&mut visual); let before_0 = bounds(&mut visual, "test-dock-pane-0").expect("pane 0 missing"); let before_1 = bounds(&mut visual, "test-dock-pane-1").expect("pane 1 missing"); let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); drag( &mut visual, center_of(seam), center_of(seam) + point(px(40.), px(0.)), ); let grown = bounds(&mut visual, "test-dock-pane-0").expect("pane 0 missing"); let shrunk = bounds(&mut visual, "test-dock-pane-1").expect("pane 1 missing"); assert!( grown.size.width > before_0.size.width, "the seam leads pane 0" ); assert!(shrunk.size.width < before_1.size.width, "pane 1 gives way"); // Double-clicking the seam restores the default ratio. let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); press(&mut visual, center_of(seam), 2); redraw(&mut visual); let reset_0 = bounds(&mut visual, "test-dock-pane-0").expect("pane 0 missing"); let reset_1 = bounds(&mut visual, "test-dock-pane-1").expect("pane 1 missing"); // The flex split may round an odd-pixel span by a hair. assert!((reset_0.size.width - reset_1.size.width).abs() <= px(1.)); // A huge drag clamps instead of vanishing a side. let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); drag( &mut visual, center_of(seam), center_of(seam) + point(px(2000.), px(0.)), ); let clamped = bounds(&mut visual, "test-dock-pane-1").expect("clamped pane missing"); assert!(clamped.size.width > px(0.)); } #[test] fn dragging_a_tab_to_a_pane_edge_splits_it() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); // Drag explore's tab onto the right edge band of the pane's content. let tab = bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").expect("tab missing"); let content = bounds(&mut visual, "test-dock-content-0").expect("content missing"); let drop_at = point( content.right() - content.size.width * 0.125, content.center().y, ); visual.simulate_mouse_down(center_of(tab), MouseButton::Left, Modifiers::default()); // Past the drag threshold, the drag starts and the drop zones mount. visual.simulate_event(MouseMoveEvent { position: center_of(tab) + point(px(8.), px(2.)), pressed_button: Some(MouseButton::Left), modifiers: Modifiers::default(), }); redraw(&mut visual); visual.simulate_event(MouseMoveEvent { position: drop_at, pressed_button: Some(MouseButton::Left), modifiers: Modifiers::default(), }); visual.simulate_mouse_up(drop_at, MouseButton::Left, Modifiers::default()); redraw(&mut visual); assert!( bounds(&mut visual, "resize-handle").is_some(), "the drop should have split the pane" ); assert!( bounds(&mut visual, "test-dock-pane-1-tabs-tab-explore").is_some(), "the dragged tab lives in the new pane" ); assert!(bounds(&mut visual, "panel-explore").is_some()); } #[test] fn moving_a_panel_merges_panes_and_collapses_the_source() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); cx.update(|cx| { dock.update(cx, |dock, cx| { dock.split_panel("explore", SplitDirection::Right, cx) }); }); redraw(&mut visual); cx.update(|cx| { dock.update(cx, |dock, cx| dock.move_panel("explore", "inbox", cx)); }); redraw(&mut visual); assert!( bounds(&mut visual, "resize-handle").is_none(), "the emptied source pane collapses" ); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").is_some()); assert!( bounds(&mut visual, "panel-explore").is_some(), "the moved tab activates in its new pane" ); } #[test] fn close_others_keeps_one_and_close_pane_empties() { let mut cx = TestAppContext::single(); let (dock, mut visual) = dock_window(&mut cx); register(&dock, &["inbox", "explore", "settings"], &mut cx); open(&mut visual, &dock, "inbox"); open(&mut visual, &dock, "explore"); open(&mut visual, &dock, "settings"); cx.update(|cx| { dock.update(cx, |dock, cx| dock.close_others("inbox", cx)); }); redraw(&mut visual); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").is_none()); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-settings").is_none()); assert!(bounds(&mut visual, "panel-inbox").is_some()); cx.update(|cx| { dock.update(cx, |dock, cx| dock.close_pane("inbox", cx)); }); redraw(&mut visual); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_none()); assert!( bounds(&mut visual, "panel-inbox").is_none(), "the emptied pane renders nothing" ); // The dock is reusable: opening again fills the empty root pane. open(&mut visual, &dock, "inbox"); assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); }