1use std::{
9 cell::{Cell, RefCell},
10 collections::HashSet,
11 rc::Rc,
12 sync::Arc,
13};
14
15use gpui::{
16 Anchor, AnyElement, AnyView, App, AppContext as _, Context, Div, Empty,
17 InteractiveElement as _, IntoElement, ParentElement as _, Render, ScrollHandle, SharedString,
18 Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div,
19 prelude::FluentBuilder as _, px,
20};
21use gpui_base::{
22 dock::{
23 AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef, PanelId,
24 TabGroupContext, TabGroupRenderer,
25 },
26 spring,
27};
28use rust_i18n::t;
29
30use crate::{
31 ActiveTheme as _, IconName, Selectable as _, Sizable as _,
32 button::{Button, ButtonCustomVariant, ButtonVariants as _},
33 dock::{
34 ClosePanel, PanelControl, PanelHandle, PanelStyle, SkinShared, ToggleZoom,
35 toggle_dock_by_user,
36 },
37 h_flex,
38 menu::DropdownMenu as _,
39 tab::{Tab, TabBar},
40};
41
42const ZOOM_CONTROL_SELECTOR: &str = "dock-tab-bar-zoom-control";
45
46const CLOSE_BUTTON_SELECTOR: &str = "dock-tab-close-button";
48
49const TAB_SELECTOR: &str = "dock-tab";
51
52const DOCK_TOGGLE_SELECTOR: &str = "dock-toggle";
55
56const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = gpui::size(px(96.), px(30.));
59
60pub struct DragPanelPreview {
65 panel: Arc<dyn gpui_base::dock::PanelView>,
66}
67
68impl Render for DragPanelPreview {
69 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
70 div()
71 .id("drag-panel")
72 .cursor_grab()
73 .py_1()
74 .px_3()
75 .w_24()
76 .overflow_hidden()
77 .whitespace_nowrap()
78 .border_1()
79 .border_color(cx.theme().border)
80 .rounded(cx.theme().radius)
81 .text_color(cx.theme().tab_foreground)
82 .bg(cx.theme().tokens.tab_active)
83 .opacity(0.75)
84 .child(panel_title(&self.panel, window, cx))
85 }
86}
87
88pub(crate) fn panel_title(
91 panel: &Arc<dyn gpui_base::dock::PanelView>,
92 window: &mut Window,
93 cx: &mut App,
94) -> AnyElement {
95 let Some(handle) = PanelHandle::of(panel) else {
96 let name = panel.panel_name(cx);
97 warn_unwrapped_once(panel.panel_id(cx), name);
98 return SharedString::from(name).into_any_element();
99 };
100 handle.title(window, cx)
101}
102
103thread_local! {
104 static WARNED_UNWRAPPED: RefCell<HashSet<PanelId>> = RefCell::new(HashSet::new());
115}
116
117fn warn_unwrapped_once(panel: PanelId, name: &'static str) {
124 if !WARNED_UNWRAPPED.with(|warned| warned.borrow_mut().insert(panel)) {
125 return;
126 }
127 tracing::warn!(
128 panel = name,
129 "dock panel reached the skin without its presentation handle, so it \
130 draws its panel name instead of its title; install it with \
131 `gpui_component::dock::panel_handle(..)` and `DockLayout::panel_view` \
132 / `DockArea::add_panel_view` rather than `DockLayout::panel` / \
133 `DockArea::add_panel`"
134 );
135}
136
137fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
148 let panel = group.active_panel()?;
149 panel
150 .zoomable(cx)
151 .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
152 .flatten()
153}
154
155fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
164 group
165 .is_draggable()
166 .then(|| group.drag_panel(ix, cx))
167 .flatten()
168}
169
170fn left_top_group(node: &PaneNode) -> Option<NodeId> {
173 match node.kind() {
174 PaneRef::Tabs { .. } => Some(node.id()),
175 PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
176 }
177}
178
179fn right_top_group(node: &PaneNode) -> Option<NodeId> {
183 match node.kind() {
184 PaneRef::Tabs { .. } => Some(node.id()),
185 PaneRef::Split { axis, children, .. } => match axis {
186 gpui::Axis::Vertical => children.first(),
187 gpui::Axis::Horizontal => children.last(),
188 }
189 .and_then(right_top_group),
190 }
191}
192
193pub(crate) struct TabGroupSkin {
199 shared: Rc<SkinShared>,
200 scroll_handle: ScrollHandle,
201 last_active_ix: Rc<Cell<Option<usize>>>,
205}
206
207impl TabGroupSkin {
208 pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
209 Self {
210 shared,
211 scroll_handle: ScrollHandle::default(),
212 last_active_ix: Rc::new(Cell::new(None)),
213 }
214 }
215
216 fn dock_toggle_button(
219 &self,
220 placement: DockPlacement,
221 group: &TabGroupContext,
222 cx: &mut App,
223 ) -> Option<Button> {
224 if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
225 return None;
226 }
227
228 let area = self.shared.area().upgrade()?;
229 let area = area.read(cx);
230 if !area.is_dock_collapsible(placement) {
233 return None;
234 }
235
236 let designated = match placement {
237 DockPlacement::Left => area
238 .layout(DockPlacement::Center)
239 .and_then(|tree| left_top_group(tree.root())),
240 DockPlacement::Right => area
241 .layout(DockPlacement::Center)
242 .and_then(|tree| right_top_group(tree.root())),
243 DockPlacement::Bottom => area
244 .layout(DockPlacement::Bottom)
245 .and_then(|tree| left_top_group(tree.root())),
246 DockPlacement::Center => None,
247 };
248 if designated != Some(group.node()) {
249 return None;
250 }
251
252 let is_open = area.is_dock_open(placement);
253 let icon = match (placement, is_open) {
254 (DockPlacement::Left, true) => IconName::PanelLeft,
255 (DockPlacement::Left, false) => IconName::PanelLeftOpen,
256 (DockPlacement::Right, true) => IconName::PanelRight,
257 (DockPlacement::Right, false) => IconName::PanelRightOpen,
258 (DockPlacement::Bottom, true) => IconName::PanelBottom,
259 (DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
260 (DockPlacement::Center, _) => return None,
261 };
262
263 let area = self.shared.area().clone();
264 Some(
265 Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
266 .icon(icon)
267 .xsmall()
268 .ghost()
269 .tab_stop(false)
270 .tooltip(match is_open {
271 true => t!("Dock.Collapse"),
272 false => t!("Dock.Expand"),
273 })
274 .debug_selector(move || format!("{DOCK_TOGGLE_SELECTOR}-{placement:?}"))
275 .on_click(move |_, window, cx| {
276 _ = area.update(cx, |area, cx| {
277 toggle_dock_by_user(area, placement, window, cx)
278 });
279 }),
280 )
281 }
282
283 fn render_toolbar(
286 &self,
287 group: &TabGroupContext,
288 window: &mut Window,
289 cx: &mut App,
290 ) -> impl IntoElement {
291 if group.is_collapsed() {
292 return div();
293 }
294
295 let zoomed = group.is_zoomed();
296 let handle = group.active_panel().and_then(PanelHandle::of);
297 let control = zoom_control(group, cx);
298 let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
299 let menu_zoom = control.is_some_and(|control| control.menu_visible());
300 let closable = group.is_closable();
301 let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
302 let panel = handle.map(|handle| handle.panel());
303
304 h_flex()
305 .gap_1()
306 .occlude()
307 .when_some(buttons, |this, buttons| {
308 this.children(
309 buttons
310 .into_iter()
311 .map(|button| button.xsmall().ghost().tab_stop(false)),
312 )
313 })
314 .when_some(
315 match (zoomed, toolbar_zoom) {
316 (true, _) => Some(("zoom-out", IconName::Minimize, t!("Dock.Zoom Out"))),
317 (false, true) => Some(("zoom-in", IconName::Maximize, t!("Dock.Zoom In"))),
318 (false, false) => None,
319 },
320 |this, (id, icon, tooltip)| {
321 this.child(
322 Button::new(id)
323 .icon(icon)
324 .xsmall()
325 .ghost()
326 .tab_stop(false)
327 .tooltip_with_action(tooltip, &ToggleZoom, None)
328 .selected(zoomed)
329 .debug_selector(|| ZOOM_CONTROL_SELECTOR.to_string())
334 .on_click({
335 let group = group.clone();
336 move |_, window, cx| group.toggle_zoom(window, cx)
337 }),
338 )
339 },
340 )
341 .child(
342 Button::new("menu")
343 .icon(IconName::Ellipsis)
344 .xsmall()
345 .ghost()
346 .tab_stop(false)
347 .dropdown_menu(move |menu, window, cx| {
348 menu.when_some(panel.clone(), |menu, panel| {
349 panel.dropdown_menu(menu, window, cx)
350 })
351 .separator()
352 .menu_with_disabled(
353 match zoomed {
354 true => t!("Dock.Zoom Out"),
355 false => t!("Dock.Zoom In"),
356 },
357 Box::new(ToggleZoom),
358 !menu_zoom,
359 )
360 .when(closable, |menu| {
361 menu.separator()
362 .menu(t!("Dock.Close"), Box::new(ClosePanel))
363 })
364 })
365 .anchor(Anchor::TopRight),
366 )
367 }
368
369 fn render_title(
371 &self,
372 group: &TabGroupContext,
373 ix: usize,
374 window: &mut Window,
375 cx: &mut App,
376 ) -> AnyElement {
377 let panel = &group.panels()[ix];
378 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
379 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
380 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
381 let has_leading = left_button.is_some() || bottom_button.is_some();
382 let handle = PanelHandle::of(panel);
383 let title_style = handle.and_then(|handle| handle.title_style(cx));
384 let drag = tab_drag(group, ix, cx);
385
386 h_flex()
387 .justify_between()
388 .h(px(30.))
389 .py_2()
390 .pl_3()
391 .pr_2()
392 .when(left_button.is_some(), |this| this.pl_2())
393 .when(right_button.is_some(), |this| this.pr_2())
394 .when_some(title_style, |this, style| {
395 this.bg(style.background).text_color(style.foreground)
396 })
397 .when(has_leading, |this| {
398 this.child(
399 h_flex()
400 .flex_shrink_0()
401 .mr_1()
402 .gap_1()
403 .children(left_button)
404 .children(bottom_button),
405 )
406 })
407 .child(
408 div()
409 .id("tab")
410 .flex_1()
411 .min_w_16()
412 .overflow_hidden()
413 .text_ellipsis()
414 .whitespace_nowrap()
415 .child(panel_title(panel, window, cx))
416 .when_some(drag, |this, drag| {
417 this.on_drag(drag, {
418 let panel = panel.clone();
419 move |drag, offset, _, cx| {
420 cx.stop_propagation();
421 drag.set_drag_offset(offset);
422 drag.set_preview_size(DRAG_PREVIEW_SIZE);
423 cx.new(|_| DragPanelPreview {
424 panel: panel.clone(),
425 })
426 }
427 })
428 }),
429 )
430 .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
431 .child(
432 h_flex()
433 .flex_shrink_0()
434 .ml_1()
435 .gap_1()
436 .child(self.render_toolbar(group, window, cx))
437 .children(right_button),
438 )
439 .into_any_element()
440 }
441
442 fn render_tabs(
444 &self,
445 group: &TabGroupContext,
446 window: &mut Window,
447 cx: &mut App,
448 ) -> AnyElement {
449 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
450 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
451 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
452 let has_leading = left_button.is_some() || bottom_button.is_some();
453 let is_bottom_dock = bottom_button.is_some();
454 let collapsed = group.is_collapsed();
455
456 let droppable = group.is_droppable();
457 let tabs_count = group.panels().len();
458 let active_ix = group.active_ix();
459 let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
460 let visible: Vec<usize> = group
461 .panels()
462 .iter()
463 .enumerate()
464 .filter(|(_, panel)| panel.visible(cx))
465 .map(|(ix, _)| ix)
466 .collect();
467 let displayed_ix = displayed.and_then(|displayed| {
468 group
469 .panels()
470 .iter()
471 .position(|panel| panel.panel_id(cx) == displayed)
472 });
473
474 if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix) {
477 if let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix) {
478 self.scroll_handle.scroll_to_item(visible_ix);
479 }
480 }
481
482 TabBar::new("tab-bar")
483 .track_scroll(&self.scroll_handle)
484 .when(has_leading, |this| {
485 this.prefix(
486 h_flex()
487 .items_center()
488 .top_0()
489 .right(-px(1.))
491 .border_r_1()
492 .border_b_1()
493 .h_full()
494 .border_color(cx.theme().border)
495 .bg(cx.theme().tokens.tab_bar)
496 .px_2()
497 .children(left_button)
498 .children(bottom_button),
499 )
500 })
501 .children(
502 visible
503 .into_iter()
504 .map(|ix| {
505 let panel = &group.panels()[ix];
506 let handle = PanelHandle::of(panel);
507 let drag = tab_drag(group, ix, cx);
508
509 Tab::new()
510 .ix(ix)
511 .debug_selector(move || format!("{TAB_SELECTOR}-{ix}"))
512 .tab_bar_prefix(has_leading)
513 .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
514 Some(tab_name) => this.child(tab_name),
515 None => this.child(panel_title(panel, window, cx)),
516 })
517 .when(
523 !collapsed
524 && self.shared.close_button_visible.get()
525 && group.is_panel_closable(panel.panel_id(cx), cx),
526 |this| {
527 this.suffix(
528 Button::new(("close-tab", ix))
529 .icon(IconName::Close)
530 .xsmall()
531 .custom(
537 ButtonCustomVariant::new(cx)
538 .foreground(cx.theme().secondary_foreground)
539 .hover(*cx.theme().tokens.secondary_hover)
540 .active(*cx.theme().tokens.secondary_active),
541 )
542 .ml(-px(8.))
549 .mr_2()
550 .tab_stop(false)
551 .debug_selector(|| CLOSE_BUTTON_SELECTOR.to_string())
552 .on_click({
553 let group = group.clone();
554 let panel_id = panel.panel_id(cx);
555 move |_, window, cx| {
556 cx.stop_propagation();
557 group.close(panel_id, window, cx);
558 }
559 }),
560 )
561 },
562 )
563 .selected(!collapsed && Some(ix) == displayed_ix)
569 .on_click({
570 let group = group.clone();
571 let area = self.shared.area().clone();
572 let last_active_ix = self.last_active_ix.clone();
573 move |_, window, cx| {
574 last_active_ix.set(Some(ix));
577 group.select_tab(ix, window, cx);
578
579 if is_bottom_dock && collapsed {
582 _ = area.update(cx, |area, cx| {
583 toggle_dock_by_user(
584 area,
585 DockPlacement::Bottom,
586 window,
587 cx,
588 )
589 });
590 }
591 }
592 })
593 .when(!collapsed, |this| {
596 this.when_some(drag, |this, drag| {
597 this.on_drag(drag, {
598 let panel = panel.clone();
599 move |drag, offset, _, cx| {
600 cx.stop_propagation();
601 drag.set_drag_offset(offset);
602 drag.set_preview_size(DRAG_PREVIEW_SIZE);
603 cx.new(|_| DragPanelPreview {
604 panel: panel.clone(),
605 })
606 }
607 })
608 })
609 .when(droppable, |this| {
610 this.drag_over::<DragPanel>(|this, _, _, cx| {
611 this.rounded_l_none()
612 .border_l_2()
613 .border_r_0()
614 .border_color(cx.theme().drag_border)
615 })
616 .on_drop({
617 let group = group.clone();
618 move |drag: &DragPanel, window, cx| {
619 group.drop_panel(
620 drag.clone(),
621 Some(ix),
622 true,
623 window,
624 cx,
625 );
626 }
627 })
628 .drag_over::<AnyDrag>(|this, _, _, cx| {
629 this.rounded_l_none()
630 .border_l_2()
631 .border_r_0()
632 .border_color(cx.theme().drag_border)
633 })
634 .on_drop({
635 let group = group.clone();
636 move |item: &AnyDrag, window, cx| {
637 group.drop_item(item.clone(), None, window, cx);
638 }
639 })
640 })
641 })
642 })
643 .collect::<Vec<_>>(),
644 )
645 .last_empty_space(
646 div()
648 .id("tab-bar-empty-space")
649 .h_full()
650 .flex_grow_1()
651 .min_w_16()
652 .when(droppable, |this| {
653 this.drag_over::<DragPanel>(|this, _, _, cx| {
654 this.bg(cx.theme().tokens.drop_target)
655 })
656 .on_drop({
657 let group = group.clone();
658 let node = group.node();
659 move |drag: &DragPanel, window, cx| {
660 let ix = (drag.source() == node).then(|| tabs_count - 1);
664 group.drop_panel(drag.clone(), ix, false, window, cx);
665 }
666 })
667 .drag_over::<AnyDrag>(|this, _, _, cx| {
668 this.bg(cx.theme().tokens.drop_target)
669 })
670 .on_drop({
671 let group = group.clone();
672 move |item: &AnyDrag, window, cx| {
673 group.drop_item(item.clone(), None, window, cx);
674 }
675 })
676 }),
677 )
678 .when(!collapsed, |this| {
679 this.suffix(
680 h_flex()
681 .items_center()
682 .top_0()
683 .right_0()
684 .border_l_1()
685 .border_b_1()
686 .h_full()
687 .border_color(cx.theme().border)
688 .bg(cx.theme().tokens.tab_bar)
689 .px_2()
690 .gap_1()
691 .children(
692 group
693 .active_panel()
694 .and_then(PanelHandle::of)
695 .and_then(|handle| handle.title_suffix(window, cx)),
696 )
697 .child(self.render_toolbar(group, window, cx))
698 .children(right_button),
699 )
700 })
701 .into_any_element()
702 }
703}
704
705impl TabGroupRenderer for TabGroupSkin {
706 fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
707 let control = zoom_control(group, cx);
708
709 div()
712 .id("tab-panel")
713 .bg(cx.theme().tokens.background)
714 .when(!group.is_collapsed(), |this| {
717 this.on_action({
718 let group = group.clone();
719 move |_: &ToggleZoom, window, cx| {
720 if !group.is_zoomed() && control.is_none() {
726 return;
727 }
728 group.toggle_zoom(window, cx);
729 }
730 })
731 .on_action({
732 let group = group.clone();
733 move |_: &ClosePanel, window, cx| {
734 let Some(panel) = group.active_panel() else {
735 return;
736 };
737 let panel = panel.panel_id(cx);
738 group.close(panel, window, cx);
739 }
740 })
741 })
742 }
743
744 fn content_frame(
745 &self,
746 group: &TabGroupContext,
747 _: &mut Window,
748 cx: &mut App,
749 ) -> Stateful<Div> {
750 let padded = group.panels().len() > 1
751 && group
752 .active_panel()
753 .and_then(PanelHandle::of)
754 .is_none_or(|handle| handle.inner_padding(cx));
755
756 div().id("active-panel").when(padded, |this| this.pt_2())
759 }
760
761 fn render_tab_bar(
762 &self,
763 group: &TabGroupContext,
764 window: &mut Window,
765 cx: &mut App,
766 ) -> AnyElement {
767 let visible: Vec<usize> = group
768 .panels()
769 .iter()
770 .enumerate()
771 .filter(|(_, panel)| panel.visible(cx))
772 .map(|(ix, _)| ix)
773 .collect();
774
775 match visible.as_slice() {
776 [] => Empty.into_any_element(),
777 [ix] if self.shared.panel_style() == PanelStyle::Auto => {
778 let panel = &group.panels()[*ix];
780 if PanelHandle::of(panel).is_some_and(|handle| !handle.title_bar(cx)) {
781 return Empty.into_any_element();
782 }
783 self.render_title(group, *ix, window, cx)
784 }
785 _ => self.render_tabs(group, window, cx),
786 }
787 }
788
789 fn render_active_panel(
790 &self,
791 panel: AnyView,
792 group: &TabGroupContext,
793 _: &mut Window,
794 _: &mut App,
795 ) -> AnyElement {
796 if group.is_collapsed() {
797 return Empty.into_any_element();
798 }
799
800 div()
801 .id("tab-content")
802 .overflow_y_scroll()
803 .overflow_x_hidden()
804 .flex_1()
805 .child(panel.cached(StyleRefinement::default().absolute().size_full()))
806 .into_any_element()
807 }
808
809 fn render_drop_indicator(
810 &self,
811 indicator: DropIndicator,
812 window: &mut Window,
813 cx: &mut App,
814 ) -> Option<AnyElement> {
815 let to = indicator.to();
816 let id = "drop-placeholder";
822 let placeholder_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
823 let left = spring((id, "left"), to.origin().x, placeholder_spring, window, cx);
824 let top = spring((id, "top"), to.origin().y, placeholder_spring, window, cx);
825 let width = spring(
826 (id, "width"),
827 to.size().width,
828 placeholder_spring,
829 window,
830 cx,
831 );
832 let height = spring(
833 (id, "height"),
834 to.size().height,
835 placeholder_spring,
836 window,
837 cx,
838 );
839
840 Some(
841 div()
842 .absolute()
843 .bg(cx.theme().tokens.drop_target)
844 .left(left)
845 .top(top)
846 .w(width)
847 .h(height)
848 .into_any_element(),
849 )
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use std::{
856 cell::{Cell, RefCell},
857 sync::Arc,
858 };
859
860 use gpui::{
861 Entity, EventEmitter, FocusHandle, Focusable, Modifiers, MouseButton, Pixels,
862 TestAppContext, VisualTestContext,
863 };
864 use gpui_base::dock::{DockArea, DockAreaRenderer, DockLayout, DockPlacement, PanelEvent};
865
866 use super::*;
867 use crate::{
868 ElementExt as _,
869 dock::{
870 DockSkin, Panel, panel_handle,
871 test_support::{HideableProbe, MeasuredProbe},
872 },
873 };
874
875 struct Probe {
876 focus_handle: FocusHandle,
877 }
878
879 impl Probe {
880 fn new(cx: &mut App) -> Entity<Self> {
881 cx.new(|cx| Self {
882 focus_handle: cx.focus_handle(),
883 })
884 }
885 }
886
887 impl gpui_base::dock::Panel for Probe {
888 fn panel_name(&self) -> &'static str {
889 "Probe"
890 }
891 }
892
893 impl Panel for Probe {}
894 impl EventEmitter<PanelEvent> for Probe {}
895
896 impl Focusable for Probe {
897 fn focus_handle(&self, _: &App) -> FocusHandle {
898 self.focus_handle.clone()
899 }
900 }
901
902 impl Render for Probe {
903 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
904 Empty
905 }
906 }
907
908 #[derive(Default)]
911 struct Recorded {
912 draggable: Vec<bool>,
914 }
915
916 struct Recorder {
917 log: Rc<RefCell<Recorded>>,
918 }
919
920 impl TabGroupRenderer for Recorder {
921 fn render_tab_bar(
922 &self,
923 group: &TabGroupContext,
924 _: &mut Window,
925 cx: &mut App,
926 ) -> AnyElement {
927 let mut log = self.log.borrow_mut();
928 for ix in 0..group.panels().len() {
929 log.draggable.push(tab_drag(group, ix, cx).is_some());
930 }
931 Empty.into_any_element()
932 }
933 }
934
935 impl DockAreaRenderer for Recorder {
936 fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
937 div().id("recorder").size_full()
938 }
939
940 fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
941 Rc::new(Recorder {
942 log: self.log.clone(),
943 })
944 }
945 }
946
947 fn recording_area(
948 cx: &mut TestAppContext,
949 ) -> (
950 Entity<DockArea>,
951 Rc<RefCell<Recorded>>,
952 &mut VisualTestContext,
953 ) {
954 cx.update(|cx| {
955 crate::init(cx);
956 });
957 let log = Rc::new(RefCell::new(Recorded::default()));
958 let renderer = Rc::new(Recorder { log: log.clone() });
959 let (area, cx) = cx.add_window_view(|window, cx| {
960 DockArea::new("skin", None, window, cx).with_renderer(renderer)
961 });
962 (area, log, cx)
963 }
964
965 #[gpui::test]
969 fn the_last_group_in_a_dock_offers_no_drag(cx: &mut TestAppContext) {
970 let (area, log, cx) = recording_area(cx);
971
972 cx.update(|window, cx| {
973 let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
974 area.update(cx, |area, cx| area.set_center(layout, window, cx));
975 });
976 cx.run_until_parked();
977 log.borrow_mut().draggable.clear();
978 cx.update(|window, cx| window.draw(cx).clear(cx));
979
980 assert_eq!(
981 log.borrow().draggable,
982 vec![false],
983 "the only visible panel in the dock has nowhere to go, so its tab \
984 must not start a drag"
985 );
986 }
987
988 #[gpui::test]
989 fn a_group_beside_another_offers_a_drag(cx: &mut TestAppContext) {
990 let (area, log, cx) = recording_area(cx);
991
992 cx.update(|window, cx| {
993 let layout = DockLayout::h_split()
994 .child(
995 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
996 None,
997 )
998 .child(
999 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
1000 None,
1001 );
1002 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1003 });
1004 cx.run_until_parked();
1005 log.borrow_mut().draggable.clear();
1006 cx.update(|window, cx| window.draw(cx).clear(cx));
1007
1008 assert_eq!(
1009 log.borrow().draggable,
1010 vec![true, true],
1011 "each group has somewhere to go, so both tabs start a drag"
1012 );
1013 }
1014
1015 struct ToolbarZoomProbe {
1018 focus_handle: FocusHandle,
1019 }
1020
1021 impl ToolbarZoomProbe {
1022 fn new(cx: &mut App) -> Entity<Self> {
1023 cx.new(|cx| Self {
1024 focus_handle: cx.focus_handle(),
1025 })
1026 }
1027 }
1028
1029 impl gpui_base::dock::Panel for ToolbarZoomProbe {
1030 fn panel_name(&self) -> &'static str {
1031 "ToolbarZoomProbe"
1032 }
1033 }
1034
1035 impl Panel for ToolbarZoomProbe {
1036 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1037 Some(PanelControl::Toolbar)
1038 }
1039 }
1040
1041 impl EventEmitter<PanelEvent> for ToolbarZoomProbe {}
1042
1043 impl Focusable for ToolbarZoomProbe {
1044 fn focus_handle(&self, _: &App) -> FocusHandle {
1045 self.focus_handle.clone()
1046 }
1047 }
1048
1049 impl Render for ToolbarZoomProbe {
1050 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1051 Empty
1052 }
1053 }
1054
1055 struct NoControlProbe {
1058 focus_handle: FocusHandle,
1059 }
1060
1061 impl NoControlProbe {
1062 fn new(cx: &mut App) -> Entity<Self> {
1063 cx.new(|cx| Self {
1064 focus_handle: cx.focus_handle(),
1065 })
1066 }
1067 }
1068
1069 impl gpui_base::dock::Panel for NoControlProbe {
1070 fn panel_name(&self) -> &'static str {
1071 "NoControlProbe"
1072 }
1073 }
1074
1075 impl Panel for NoControlProbe {
1076 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1077 None
1078 }
1079 }
1080
1081 impl EventEmitter<PanelEvent> for NoControlProbe {}
1082
1083 impl Focusable for NoControlProbe {
1084 fn focus_handle(&self, _: &App) -> FocusHandle {
1085 self.focus_handle.clone()
1086 }
1087 }
1088
1089 impl Render for NoControlProbe {
1090 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1091 Empty
1092 }
1093 }
1094
1095 struct UnzoomableProbe {
1098 focus_handle: FocusHandle,
1099 }
1100
1101 impl gpui_base::dock::Panel for UnzoomableProbe {
1102 fn panel_name(&self) -> &'static str {
1103 "UnzoomableProbe"
1104 }
1105
1106 fn zoomable(&self, _: &App) -> bool {
1107 false
1108 }
1109 }
1110
1111 impl Panel for UnzoomableProbe {
1112 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1113 Some(PanelControl::Toolbar)
1114 }
1115 }
1116
1117 impl EventEmitter<PanelEvent> for UnzoomableProbe {}
1118
1119 impl Focusable for UnzoomableProbe {
1120 fn focus_handle(&self, _: &App) -> FocusHandle {
1121 self.focus_handle.clone()
1122 }
1123 }
1124
1125 impl Render for UnzoomableProbe {
1126 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1127 Empty
1128 }
1129 }
1130
1131 fn drew_zoom_control(
1138 cx: &mut TestAppContext,
1139 panel: impl FnOnce(&mut App) -> Arc<dyn gpui_base::dock::PanelView>,
1140 ) -> bool {
1141 cx.update(|cx| {
1142 crate::init(cx);
1143 });
1144 let (area, cx) = cx.add_window_view(|window, cx| {
1145 let skin = DockSkin::new(cx);
1146 DockArea::new("skin", None, window, cx).with_renderer(skin)
1147 });
1148
1149 cx.update(|window, cx| {
1150 let layout = DockLayout::tabs().panel_view(panel(cx), cx);
1151 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1152 });
1153 cx.run_until_parked();
1154 cx.update(|window, cx| window.draw(cx).clear(cx));
1155 cx.debug_bounds(ZOOM_CONTROL_SELECTOR).is_some()
1156 }
1157
1158 #[gpui::test]
1162 fn a_panel_base_will_not_zoom_gets_no_zoom_control(cx: &mut TestAppContext) {
1163 let drew = drew_zoom_control(cx, |cx| {
1164 panel_handle(cx.new(|cx| UnzoomableProbe {
1165 focus_handle: cx.focus_handle(),
1166 }))
1167 });
1168
1169 assert!(
1170 !drew,
1171 "the panel names a place for the control, but base refuses the zoom"
1172 );
1173 }
1174
1175 #[gpui::test]
1179 fn a_zoomable_panel_gets_its_zoom_control(cx: &mut TestAppContext) {
1180 let drew = drew_zoom_control(cx, |cx| panel_handle(ToolbarZoomProbe::new(cx)));
1181
1182 assert!(
1183 drew,
1184 "a zoomable panel asking for a toolbar control gets one"
1185 );
1186 }
1187
1188 #[gpui::test]
1198 fn the_centre_and_the_bottom_dock_share_the_column(cx: &mut TestAppContext) {
1199 cx.update(|cx| {
1200 crate::init(cx);
1201 });
1202 let centre = Rc::new(Cell::new(px(0.)));
1203 let bottom = Rc::new(Cell::new(px(0.)));
1204 let (area, cx) = cx.add_window_view(|window, cx| {
1205 let skin = DockSkin::new(cx);
1206 DockArea::new("skin", None, window, cx).with_renderer(skin)
1207 });
1208
1209 let (centre_probe, bottom_probe) = (centre.clone(), bottom.clone());
1210 cx.update(|window, cx| {
1211 let centre_panel = MeasuredProbe::new(centre_probe, cx);
1212 let bottom_panel = MeasuredProbe::new(bottom_probe, cx);
1213 area.update(cx, |area, cx| {
1214 area.set_center(
1218 DockLayout::v_split().child(
1219 DockLayout::h_split().child(
1220 DockLayout::tabs().panel_view(panel_handle(centre_panel), cx),
1221 None,
1222 ),
1223 None,
1224 ),
1225 window,
1226 cx,
1227 );
1228 area.set_dock(
1229 DockPlacement::Bottom,
1230 DockLayout::tabs().panel_view(panel_handle(bottom_panel), cx),
1231 window,
1232 cx,
1233 );
1234 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
1235 });
1236 });
1237 cx.run_until_parked();
1238 cx.update(|window, cx| window.draw(cx).clear(cx));
1239
1240 let window_height = cx.update(|window, _| window.viewport_size().height);
1241 assert!(
1242 centre.get() > px(0.),
1243 "the centre panel must receive height; it got {:?}",
1244 centre.get()
1245 );
1246 assert!(
1247 bottom.get() > px(0.),
1248 "the bottom dock's panel must receive height; it got {:?}",
1249 bottom.get()
1250 );
1251 assert!(
1252 centre.get() < window_height - px(150.),
1253 "the centre must give the 200px bottom dock its share; the centre \
1254 got {:?} of {window_height:?}",
1255 centre.get()
1256 );
1257 }
1258
1259 #[gpui::test]
1274 fn a_split_fills_its_container_whichever_slots_are_hidden(cx: &mut TestAppContext) {
1275 cx.update(|cx| {
1276 crate::init(cx);
1277 });
1278 let heights: Vec<Rc<Cell<Pixels>>> = (0..3).map(|_| Rc::new(Cell::new(px(0.)))).collect();
1279 let (area, cx) = cx.add_window_view(|window, cx| {
1280 let skin = DockSkin::new(cx);
1281 DockArea::new("skin", None, window, cx).with_renderer(skin)
1282 });
1283
1284 let slots = heights.clone();
1285 let probes = cx.update(|window, cx| {
1286 let probes: Vec<_> = slots
1287 .iter()
1288 .map(|height| HideableProbe::new(height.clone(), cx))
1289 .collect();
1290 area.update(cx, |area, cx| {
1291 area.set_dock(
1292 DockPlacement::Right,
1293 probes.iter().zip([260., 320., 200.]).fold(
1294 DockLayout::v_split(),
1295 |split, (probe, size)| {
1296 split.child(
1297 DockLayout::tabs().panel_view(panel_handle(probe.clone()), cx),
1298 Some(px(size)),
1299 )
1300 },
1301 ),
1302 window,
1303 cx,
1304 );
1305 area.set_dock_size(DockPlacement::Right, px(380.), window, cx);
1306 });
1307 probes
1308 });
1309 cx.run_until_parked();
1310 let draw = |cx: &mut VisualTestContext| {
1311 cx.update(|window, _| window.refresh());
1312 cx.update(|window, cx| window.draw(cx).clear(cx));
1313 cx.update(|window, cx| window.draw(cx).clear(cx));
1314 };
1315 draw(cx);
1316
1317 let dock_height = cx.update(|window, _| window.viewport_size().height);
1318 let drawn: Pixels = heights.iter().map(|height| height.get()).sum();
1322 let bar = (dock_height - drawn) / 3.;
1323 assert!(
1324 bar > px(0.) && bar < px(60.),
1325 "the three slots fill the dock to begin with, one tab bar each; \
1326 that leaves {bar:?} per slot of {dock_height:?}"
1327 );
1328
1329 for hidden in 1..0b111u8 {
1332 let shown = (0..3).filter(|slot| hidden & (1 << slot) == 0);
1333 cx.update(|_, cx| {
1334 for (slot, probe) in probes.iter().enumerate() {
1335 heights[slot].set(px(-1.));
1338 probe.update(cx, |probe, cx| {
1339 probe.set_visible(hidden & (1 << slot) == 0, cx)
1340 });
1341 }
1342 });
1343 cx.run_until_parked();
1344 draw(cx);
1345
1346 let mut count = 0;
1347 let mut total = px(0.);
1348 for slot in shown {
1349 assert_ne!(
1350 heights[slot].get(),
1351 px(-1.),
1352 "hiding {hidden:03b}: slot {slot} is shown and must draw"
1353 );
1354 count += 1;
1355 total += heights[slot].get();
1356 }
1357 let empty = dock_height - total - bar * count as f32;
1358 assert!(
1359 empty.abs() < px(1.),
1360 "hiding {hidden:03b}: the drawn slots must take the hidden \
1361 ones' space between them; they left {empty:?} of \
1362 {dock_height:?} empty"
1363 );
1364 }
1365 }
1366
1367 #[gpui::test]
1371 fn the_zoom_action_reaches_the_group_through_the_skin(cx: &mut TestAppContext) {
1372 cx.update(|cx| {
1373 crate::init(cx);
1374 });
1375 let (area, cx) = cx.add_window_view(|window, cx| {
1376 let skin = DockSkin::new(cx);
1377 DockArea::new("skin", None, window, cx).with_renderer(skin)
1378 });
1379
1380 let panel = cx.update(|window, cx| {
1381 let panel = Probe::new(cx);
1382 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1383 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1384 panel
1385 });
1386 cx.run_until_parked();
1387 cx.update(|window, cx| {
1388 panel.read(cx).focus_handle(cx).focus(window, cx);
1389 });
1390 cx.run_until_parked();
1391
1392 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1393 cx.dispatch_action(ToggleZoom);
1394 cx.run_until_parked();
1395 assert_eq!(
1396 cx.read(|cx| area.read(cx).is_zoomed()),
1397 true,
1398 "the skin's frame is what carries the ToggleZoom handler"
1399 );
1400
1401 cx.dispatch_action(ToggleZoom);
1402 cx.run_until_parked();
1403 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1404 }
1405
1406 #[gpui::test]
1412 fn the_zoom_action_refuses_a_panel_that_offers_no_control(cx: &mut TestAppContext) {
1413 cx.update(|cx| {
1414 crate::init(cx);
1415 });
1416 let (area, cx) = cx.add_window_view(|window, cx| {
1417 let skin = DockSkin::new(cx);
1418 DockArea::new("skin", None, window, cx).with_renderer(skin)
1419 });
1420
1421 let panel = cx.update(|window, cx| {
1422 let panel = NoControlProbe::new(cx);
1423 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1424 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1425 panel
1426 });
1427 cx.run_until_parked();
1428 cx.update(|window, cx| {
1429 panel.read(cx).focus_handle(cx).focus(window, cx);
1430 });
1431 cx.run_until_parked();
1432
1433 cx.dispatch_action(ToggleZoom);
1434 cx.run_until_parked();
1435 assert_eq!(
1436 cx.read(|cx| area.read(cx).is_zoomed()),
1437 false,
1438 "no control means no zoom, however the zoom was asked for"
1439 );
1440 }
1441
1442 #[gpui::test]
1457 fn the_panel_content_region_gets_the_height_below_the_tab_bar(cx: &mut TestAppContext) {
1458 cx.update(|cx| {
1459 crate::init(cx);
1460 });
1461 let height = Rc::new(Cell::new(px(0.)));
1462 let (area, cx) = cx.add_window_view(|window, cx| {
1463 let skin = DockSkin::new(cx);
1464 DockArea::new("skin", None, window, cx).with_renderer(skin)
1465 });
1466
1467 let measured = height.clone();
1468 cx.update(|window, cx| {
1469 let panel = MeasuredProbe::new(measured, cx);
1470 let layout = DockLayout::tabs().panel_view(panel_handle(panel), cx);
1471 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1472 });
1473 cx.run_until_parked();
1474 cx.update(|window, cx| window.draw(cx).clear(cx));
1475
1476 let window_height = cx.update(|window, _| window.viewport_size().height);
1477 let content = height.get();
1478 assert!(
1479 content > px(0.),
1480 "the panel must receive height; it got {content:?} in a {window_height:?} window"
1481 );
1482 assert!(
1485 content > window_height - px(60.),
1486 "the panel should fill what the tab bar leaves; it got {content:?} \
1487 of {window_height:?}"
1488 );
1489 }
1490
1491 #[gpui::test]
1498 fn a_collapsed_dock_ignores_the_zoom_action(cx: &mut TestAppContext) {
1499 cx.update(|cx| {
1500 crate::init(cx);
1501 });
1502 let (area, cx) = cx.add_window_view(|window, cx| {
1503 let skin = DockSkin::new(cx);
1504 DockArea::new("skin", None, window, cx).with_renderer(skin)
1505 });
1506
1507 let panel = cx.update(|window, cx| {
1508 let panel = Probe::new(cx);
1509 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1510 area.update(cx, |area, cx| {
1511 area.set_dock(DockPlacement::Bottom, layout, window, cx);
1512 area.toggle_dock(DockPlacement::Bottom, window, cx);
1513 });
1514 panel
1515 });
1516 cx.run_until_parked();
1517 cx.update(|window, cx| {
1518 panel.read(cx).focus_handle(cx).focus(window, cx);
1519 });
1520 cx.run_until_parked();
1521
1522 cx.dispatch_action(ToggleZoom);
1523 cx.run_until_parked();
1524 assert_eq!(
1525 cx.read(|cx| area.read(cx).is_zoomed()),
1526 false,
1527 "a collapsed group installs no action handler"
1528 );
1529 }
1530
1531 #[gpui::test]
1534 fn a_panel_without_a_title_bar_gets_the_whole_group(cx: &mut TestAppContext) {
1535 struct Chromeless {
1536 focus_handle: FocusHandle,
1537 height: Rc<Cell<Pixels>>,
1538 }
1539
1540 impl gpui_base::dock::Panel for Chromeless {
1541 fn panel_name(&self) -> &'static str {
1542 "Chromeless"
1543 }
1544 }
1545
1546 impl Panel for Chromeless {
1547 fn title_bar(&self, _: &App) -> bool {
1548 false
1549 }
1550 }
1551
1552 impl EventEmitter<PanelEvent> for Chromeless {}
1553
1554 impl Focusable for Chromeless {
1555 fn focus_handle(&self, _: &App) -> FocusHandle {
1556 self.focus_handle.clone()
1557 }
1558 }
1559
1560 impl Render for Chromeless {
1561 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1562 let height = self.height.clone();
1563 div()
1564 .size_full()
1565 .on_prepaint(move |bounds, _, _| height.set(bounds.size.height))
1566 }
1567 }
1568
1569 cx.update(|cx| crate::init(cx));
1570
1571 let measure = |cx: &mut TestAppContext, chromeless: bool| -> Pixels {
1572 let height = Rc::new(Cell::new(px(0.)));
1573 let (area, cx) = cx.add_window_view(|window, cx| {
1574 let skin = DockSkin::new(cx);
1575 DockArea::new("skin", None, window, cx).with_renderer(skin)
1576 });
1577 let measured = height.clone();
1578 cx.update(|window, cx| {
1579 let panel: Arc<dyn gpui_base::dock::PanelView> = if chromeless {
1580 panel_handle(cx.new(|cx| Chromeless {
1581 focus_handle: cx.focus_handle(),
1582 height: measured,
1583 }))
1584 } else {
1585 panel_handle(MeasuredProbe::new(measured, cx))
1586 };
1587 let layout = DockLayout::tabs().panel_view(panel, cx);
1588 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1589 });
1590 cx.run_until_parked();
1591 cx.update(|window, cx| window.draw(cx).clear(cx));
1592 height.get()
1593 };
1594
1595 let with_title = measure(cx, false);
1596 let without = measure(cx, true);
1597 assert!(with_title > px(0.), "the probe must have been drawn");
1598 assert_eq!(
1599 without,
1600 with_title + px(30.),
1601 "the 30px the title bar took go to the panel instead"
1602 );
1603 }
1604
1605 struct TabProbe {
1607 focus_handle: FocusHandle,
1608 closable: bool,
1609 activated: Rc<Cell<bool>>,
1610 }
1611
1612 impl TabProbe {
1613 fn new(closable: bool, cx: &mut App) -> Entity<Self> {
1614 cx.new(|cx| Self {
1615 focus_handle: cx.focus_handle(),
1616 closable,
1617 activated: Rc::new(Cell::new(false)),
1618 })
1619 }
1620 }
1621
1622 impl gpui_base::dock::Panel for TabProbe {
1623 fn panel_name(&self) -> &'static str {
1624 "TabProbe"
1625 }
1626
1627 fn closable(&self, _: &App) -> bool {
1628 self.closable
1629 }
1630
1631 fn set_active(&mut self, active: bool, _: &mut Window, _: &mut Context<Self>) {
1632 if active {
1633 self.activated.set(true);
1634 }
1635 }
1636 }
1637
1638 impl Panel for TabProbe {}
1639
1640 impl EventEmitter<PanelEvent> for TabProbe {}
1641
1642 impl Focusable for TabProbe {
1643 fn focus_handle(&self, _: &App) -> FocusHandle {
1644 self.focus_handle.clone()
1645 }
1646 }
1647
1648 impl Render for TabProbe {
1649 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1650 Empty
1651 }
1652 }
1653
1654 fn drew_close_button(cx: &mut TestAppContext, closable: bool, enabled: bool) -> bool {
1659 cx.update(|cx| crate::init(cx));
1660 let mut skin = None;
1661 let (area, cx) = cx.add_window_view(|window, cx| {
1662 let renderer = DockSkin::new(cx);
1663 skin = Some(renderer.clone());
1664 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1665 });
1666 let skin = skin.expect("skin constructed with the area");
1667 if enabled {
1668 cx.update(|_, cx| skin.set_close_button_visible(true, cx));
1669 }
1670 cx.update(|window, cx| {
1671 let under_test = TabProbe::new(closable, cx);
1672 let filler = TabProbe::new(false, cx);
1673 let layout = DockLayout::tabs()
1674 .panel_view(panel_handle(under_test), cx)
1675 .panel_view(panel_handle(filler), cx);
1676 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1677 });
1678 cx.run_until_parked();
1679 cx.update(|window, cx| window.draw(cx).clear(cx));
1680 let button = cx.debug_bounds(CLOSE_BUTTON_SELECTOR);
1681 if enabled && closable {
1682 assert_eq!(
1683 button.as_ref().map(|bounds| bounds.size.width),
1684 Some(px(20.)),
1685 "the close button keeps the standard XS hover target"
1686 );
1687 }
1688 button.is_some()
1689 }
1690
1691 #[gpui::test]
1693 fn a_closable_panel_gets_a_close_button(cx: &mut TestAppContext) {
1694 assert!(
1695 drew_close_button(cx, true, true),
1696 "a closable panel's tab must draw a close button"
1697 );
1698 }
1699
1700 #[gpui::test]
1702 fn a_non_closable_panel_gets_no_close_button(cx: &mut TestAppContext) {
1703 assert!(
1704 !drew_close_button(cx, false, true),
1705 "a panel that reports itself non-closable must draw no close button"
1706 );
1707 }
1708
1709 #[gpui::test]
1710 fn close_buttons_are_off_by_default(cx: &mut TestAppContext) {
1711 assert!(!drew_close_button(cx, true, false));
1712 }
1713
1714 #[gpui::test]
1715 fn close_button_visibility_updates_after_the_skin_setting_changes(cx: &mut TestAppContext) {
1716 cx.update(|cx| crate::init(cx));
1717 let mut skin = None;
1718 let (area, cx) = cx.add_window_view(|window, cx| {
1719 let renderer = DockSkin::new(cx);
1720 skin = Some(renderer.clone());
1721 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1722 });
1723 let skin = skin.expect("skin constructed with the area");
1724 cx.update(|window, cx| {
1725 let panel = TabProbe::new(true, cx);
1726 let filler = TabProbe::new(false, cx);
1727 area.update(cx, |area, cx| {
1728 area.set_center(
1729 DockLayout::tabs()
1730 .panel_view(panel_handle(panel), cx)
1731 .panel_view(panel_handle(filler), cx),
1732 window,
1733 cx,
1734 )
1735 });
1736 });
1737
1738 for (visible, expected) in [(false, false), (true, true), (false, false)] {
1739 cx.update(|_, cx| skin.set_close_button_visible(visible, cx));
1740 cx.run_until_parked();
1741 cx.update(|window, cx| window.draw(cx).clear(cx));
1742 assert_eq!(cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_some(), expected);
1743 }
1744 }
1745
1746 #[gpui::test]
1750 fn clicking_a_close_button_removes_only_that_tab(cx: &mut TestAppContext) {
1751 cx.update(|cx| crate::init(cx));
1752 let mut skin = None;
1753 let (area, cx) = cx.add_window_view(|window, cx| {
1754 let renderer = DockSkin::new(cx);
1755 skin = Some(renderer.clone());
1756 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1757 });
1758 cx.update(|_, cx| skin.unwrap().set_close_button_visible(true, cx));
1759
1760 let (closable_id, filler_id, activated) = cx.update(|window, cx| {
1761 let filler = TabProbe::new(false, cx);
1762 let closable = TabProbe::new(true, cx);
1763 let activated = closable.read(cx).activated.clone();
1764 let filler_handle = panel_handle(filler);
1765 let closable_handle = panel_handle(closable);
1766 let ids = (closable_handle.panel_id(cx), filler_handle.panel_id(cx));
1767 let layout = DockLayout::tabs()
1770 .panel_view(filler_handle, cx)
1771 .panel_view(closable_handle, cx);
1772 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1773 (ids.0, ids.1, activated)
1774 });
1775 cx.run_until_parked();
1776 cx.update(|window, cx| window.draw(cx).clear(cx));
1777
1778 assert!(
1779 cx.update(|_, cx| area.read(cx).panel(closable_id).is_some()),
1780 "the closable tab starts owned by the area"
1781 );
1782
1783 let button = cx
1784 .debug_bounds(CLOSE_BUTTON_SELECTOR)
1785 .expect("the non-active closable tab draws a close button");
1786 cx.simulate_mouse_down(button.center(), MouseButton::Left, Modifiers::none());
1787 cx.simulate_mouse_up(button.center(), MouseButton::Left, Modifiers::none());
1788 cx.run_until_parked();
1789
1790 assert!(
1791 cx.update(|_, cx| area.read(cx).panel(closable_id).is_none()),
1792 "the close click removes exactly the tab it belongs to"
1793 );
1794 assert!(
1795 cx.update(|_, cx| area.read(cx).panel(filler_id).is_some()),
1796 "the displayed filler tab is untouched"
1797 );
1798 assert!(
1799 !activated.get(),
1800 "stop_propagation keeps the close click from also selecting the tab"
1801 );
1802 }
1803
1804 #[gpui::test]
1808 fn a_clicked_tab_is_revealed_by_the_tab_bar_not_snapped(cx: &mut TestAppContext) {
1809 cx.update(|cx| {
1810 crate::init(cx);
1811 cx.set_reduce_motion(false);
1812 });
1813 let (area, cx) = cx.add_window_view(|window, cx| {
1814 DockArea::new("skin", None, window, cx).with_renderer(DockSkin::new(cx))
1815 });
1816 cx.simulate_resize(gpui::size(px(300.), px(300.)));
1817 cx.update(|window, cx| {
1818 let layout = (0..8).fold(DockLayout::tabs(), |layout, _| {
1819 layout.panel_view(panel_handle(Probe::new(cx)), cx)
1820 });
1821 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1822 });
1823 cx.run_until_parked();
1824 cx.update(|window, cx| window.draw(cx).clear(cx));
1825
1826 let tab = |cx: &mut VisualTestContext, ix: usize| {
1828 cx.debug_bounds(format!("{TAB_SELECTOR}-{ix}").leak())
1829 .unwrap()
1830 };
1831 let first_left = |cx: &mut VisualTestContext| tab(cx, 0).left();
1832 let start = first_left(cx);
1833 let clipped = (0..8)
1836 .map(|ix| tab(cx, ix))
1837 .find(|tab| tab.right() > px(300.))
1838 .unwrap();
1839
1840 cx.simulate_click(
1841 clipped.origin + gpui::point(px(4.), px(16.)),
1842 Modifiers::none(),
1843 );
1844 cx.update(|window, cx| window.draw(cx).clear(cx));
1845 assert_eq!(first_left(cx), start, "the first frame has not snapped");
1846
1847 let mut lefts = Vec::new();
1848 for _ in 0..60 {
1849 cx.executor()
1850 .advance_clock(std::time::Duration::from_millis(16));
1851 cx.update(|window, cx| {
1852 window.simulate_next_frame(cx);
1853 window.draw(cx).clear(cx);
1854 });
1855 lefts.push(first_left(cx));
1856 }
1857 let end = *lefts.last().unwrap();
1858 assert!(end < start, "the strip scrolled");
1859 assert!(
1860 lefts.iter().any(|left| *left < start && *left > end),
1861 "the strip slid rather than jumped: {lefts:?}"
1862 );
1863 }
1864
1865 #[gpui::test]
1868 fn the_collapse_button_reports_the_toggle(cx: &mut TestAppContext) {
1869 cx.update(|cx| crate::init(cx));
1870 let (area, cx) = cx.add_window_view(|window, cx| {
1871 DockArea::new("toggle", None, window, cx).with_renderer(DockSkin::new(cx))
1872 });
1873 cx.simulate_resize(gpui::size(px(800.), px(600.)));
1874 let events = Rc::new(RefCell::new(Vec::new()));
1875 cx.update(|window, cx| {
1876 let center = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
1877 let left = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
1878 area.update(cx, |area, cx| {
1879 area.set_center(center, window, cx);
1880 area.set_dock(DockPlacement::Left, left, window, cx);
1881 });
1882 let events = events.clone();
1883 cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
1884 events
1885 .borrow_mut()
1886 .push((event.placement(), event.is_open()));
1887 })
1888 .detach();
1889 });
1890 cx.run_until_parked();
1891 cx.update(|window, cx| window.draw(cx).clear(cx));
1892
1893 let selector = format!("{DOCK_TOGGLE_SELECTOR}-{:?}", DockPlacement::Left).leak();
1894 let button = cx
1895 .debug_bounds(selector)
1896 .expect("the left dock offers a toggle");
1897 cx.simulate_click(button.center(), Modifiers::none());
1898 cx.run_until_parked();
1899 assert!(!cx.read(|cx| area.read(cx).is_dock_open(DockPlacement::Left)));
1900 assert_eq!(*events.borrow(), vec![(DockPlacement::Left, false)]);
1901
1902 cx.update(|window, cx| {
1903 area.update(cx, |area, cx| {
1904 area.toggle_dock(DockPlacement::Left, window, cx)
1905 });
1906 });
1907 cx.run_until_parked();
1908 assert_eq!(
1909 events.borrow().len(),
1910 1,
1911 "a toggle made by code is not reported"
1912 );
1913 }
1914
1915 #[gpui::test]
1918 fn opening_the_bottom_strip_reports_the_toggle(cx: &mut TestAppContext) {
1919 cx.update(|cx| crate::init(cx));
1920 let (area, cx) = cx.add_window_view(|window, cx| {
1921 DockArea::new("strip", None, window, cx).with_renderer(DockSkin::new(cx))
1922 });
1923 cx.simulate_resize(gpui::size(px(800.), px(600.)));
1924 let events = Rc::new(RefCell::new(Vec::new()));
1925 cx.update(|window, cx| {
1926 let layout = DockLayout::tabs()
1928 .panel_view(panel_handle(Probe::new(cx)), cx)
1929 .panel_view(panel_handle(Probe::new(cx)), cx);
1930 area.update(cx, |area, cx| {
1931 area.set_dock(DockPlacement::Bottom, layout, window, cx);
1932 area.toggle_dock(DockPlacement::Bottom, window, cx);
1933 });
1934 let events = events.clone();
1935 cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
1936 events
1937 .borrow_mut()
1938 .push((event.placement(), event.is_open()));
1939 })
1940 .detach();
1941 });
1942 cx.run_until_parked();
1943 cx.update(|window, cx| window.draw(cx).clear(cx));
1944 assert!(!cx.read(|cx| area.read(cx).is_dock_open(DockPlacement::Bottom)));
1945
1946 let tab = cx
1947 .debug_bounds(format!("{TAB_SELECTOR}-0").leak())
1948 .expect("the collapsed strip still draws its tab");
1949 cx.simulate_click(tab.center(), Modifiers::none());
1950 cx.run_until_parked();
1951 assert_eq!(*events.borrow(), vec![(DockPlacement::Bottom, true)]);
1952 }
1953
1954 #[gpui::test]
1956 fn a_refused_toggle_is_not_reported(cx: &mut TestAppContext) {
1957 cx.update(|cx| crate::init(cx));
1958 let (area, cx) = cx.add_window_view(|window, cx| {
1959 DockArea::new("refuse", None, window, cx).with_renderer(DockSkin::new(cx))
1960 });
1961 let events = Rc::new(RefCell::new(Vec::new()));
1962 cx.update(|window, cx| {
1963 let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
1964 area.update(cx, |area, cx| {
1965 area.set_dock(DockPlacement::Left, layout, window, cx);
1966 area.set_dock_collapsible(DockPlacement::Left, false, window, cx);
1967 });
1968 let events = events.clone();
1969 cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
1970 events
1971 .borrow_mut()
1972 .push((event.placement(), event.is_open()));
1973 })
1974 .detach();
1975 });
1976 cx.update(|window, cx| {
1977 area.update(cx, |area, cx| {
1978 toggle_dock_by_user(area, DockPlacement::Left, window, cx)
1979 });
1980 });
1981 cx.run_until_parked();
1982 assert!(cx.read(|cx| area.read(cx).is_dock_open(DockPlacement::Left)));
1983 assert!(events.borrow().is_empty());
1984 }
1985
1986 #[gpui::test]
1990 fn a_collapsed_group_draws_no_close_button(cx: &mut TestAppContext) {
1991 cx.update(|cx| crate::init(cx));
1992 let mut skin = None;
1993 let (area, cx) = cx.add_window_view(|window, cx| {
1994 let renderer = DockSkin::new(cx);
1995 skin = Some(renderer.clone());
1996 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1997 });
1998 cx.update(|_, cx| skin.unwrap().set_close_button_visible(true, cx));
1999 cx.update(|window, cx| {
2002 let a = TabProbe::new(true, cx);
2003 let b = TabProbe::new(true, cx);
2004 let layout = DockLayout::tabs()
2005 .panel_view(panel_handle(a), cx)
2006 .panel_view(panel_handle(b), cx);
2007 area.update(cx, |area, cx| {
2008 area.set_dock(DockPlacement::Bottom, layout, window, cx);
2009 if !area.is_dock_open(DockPlacement::Bottom) {
2010 area.toggle_dock(DockPlacement::Bottom, window, cx);
2011 }
2012 });
2013 });
2014 cx.run_until_parked();
2015 cx.update(|window, cx| window.draw(cx).clear(cx));
2016 assert!(
2017 cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_some(),
2018 "an open group with two closable panels offers a close button"
2019 );
2020
2021 cx.update(|window, cx| {
2024 area.update(cx, |area, cx| {
2025 area.toggle_dock(DockPlacement::Bottom, window, cx)
2026 });
2027 });
2028 cx.run_until_parked();
2029 cx.update(|window, cx| window.draw(cx).clear(cx));
2030 assert!(
2031 cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_none(),
2032 "a collapsed group must not offer a close button"
2033 );
2034 }
2035}