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::{ClosePanel, PanelControl, PanelHandle, PanelStyle, SkinShared, ToggleZoom},
34 h_flex,
35 menu::DropdownMenu as _,
36 tab::{Tab, TabBar},
37};
38
39const ZOOM_CONTROL_SELECTOR: &str = "dock-tab-bar-zoom-control";
42
43const CLOSE_BUTTON_SELECTOR: &str = "dock-tab-close-button";
45
46const TAB_SELECTOR: &str = "dock-tab";
48
49const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = gpui::size(px(96.), px(30.));
52
53pub struct DragPanelPreview {
58 panel: Arc<dyn gpui_base::dock::PanelView>,
59}
60
61impl Render for DragPanelPreview {
62 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
63 div()
64 .id("drag-panel")
65 .cursor_grab()
66 .py_1()
67 .px_3()
68 .w_24()
69 .overflow_hidden()
70 .whitespace_nowrap()
71 .border_1()
72 .border_color(cx.theme().border)
73 .rounded(cx.theme().radius)
74 .text_color(cx.theme().tab_foreground)
75 .bg(cx.theme().tokens.tab_active)
76 .opacity(0.75)
77 .child(panel_title(&self.panel, window, cx))
78 }
79}
80
81pub(crate) fn panel_title(
84 panel: &Arc<dyn gpui_base::dock::PanelView>,
85 window: &mut Window,
86 cx: &mut App,
87) -> AnyElement {
88 let Some(handle) = PanelHandle::of(panel) else {
89 let name = panel.panel_name(cx);
90 warn_unwrapped_once(panel.panel_id(cx), name);
91 return SharedString::from(name).into_any_element();
92 };
93 handle.title(window, cx)
94}
95
96thread_local! {
97 static WARNED_UNWRAPPED: RefCell<HashSet<PanelId>> = RefCell::new(HashSet::new());
108}
109
110fn warn_unwrapped_once(panel: PanelId, name: &'static str) {
117 if !WARNED_UNWRAPPED.with(|warned| warned.borrow_mut().insert(panel)) {
118 return;
119 }
120 tracing::warn!(
121 panel = name,
122 "dock panel reached the skin without its presentation handle, so it \
123 draws its panel name instead of its title; install it with \
124 `gpui_component::dock::panel_handle(..)` and `DockLayout::panel_view` \
125 / `DockArea::add_panel_view` rather than `DockLayout::panel` / \
126 `DockArea::add_panel`"
127 );
128}
129
130fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
141 let panel = group.active_panel()?;
142 panel
143 .zoomable(cx)
144 .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
145 .flatten()
146}
147
148fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
157 group
158 .is_draggable()
159 .then(|| group.drag_panel(ix, cx))
160 .flatten()
161}
162
163fn left_top_group(node: &PaneNode) -> Option<NodeId> {
166 match node.kind() {
167 PaneRef::Tabs { .. } => Some(node.id()),
168 PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
169 }
170}
171
172fn right_top_group(node: &PaneNode) -> Option<NodeId> {
176 match node.kind() {
177 PaneRef::Tabs { .. } => Some(node.id()),
178 PaneRef::Split { axis, children, .. } => match axis {
179 gpui::Axis::Vertical => children.first(),
180 gpui::Axis::Horizontal => children.last(),
181 }
182 .and_then(right_top_group),
183 }
184}
185
186pub(crate) struct TabGroupSkin {
192 shared: Rc<SkinShared>,
193 scroll_handle: ScrollHandle,
194 last_active_ix: Rc<Cell<Option<usize>>>,
198}
199
200impl TabGroupSkin {
201 pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
202 Self {
203 shared,
204 scroll_handle: ScrollHandle::default(),
205 last_active_ix: Rc::new(Cell::new(None)),
206 }
207 }
208
209 fn dock_toggle_button(
212 &self,
213 placement: DockPlacement,
214 group: &TabGroupContext,
215 cx: &mut App,
216 ) -> Option<Button> {
217 if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
218 return None;
219 }
220
221 let area = self.shared.area().upgrade()?;
222 let area = area.read(cx);
223 if !area.is_dock_collapsible(placement) {
226 return None;
227 }
228
229 let designated = match placement {
230 DockPlacement::Left => area
231 .layout(DockPlacement::Center)
232 .and_then(|tree| left_top_group(tree.root())),
233 DockPlacement::Right => area
234 .layout(DockPlacement::Center)
235 .and_then(|tree| right_top_group(tree.root())),
236 DockPlacement::Bottom => area
237 .layout(DockPlacement::Bottom)
238 .and_then(|tree| left_top_group(tree.root())),
239 DockPlacement::Center => None,
240 };
241 if designated != Some(group.node()) {
242 return None;
243 }
244
245 let is_open = area.is_dock_open(placement);
246 let icon = match (placement, is_open) {
247 (DockPlacement::Left, true) => IconName::PanelLeft,
248 (DockPlacement::Left, false) => IconName::PanelLeftOpen,
249 (DockPlacement::Right, true) => IconName::PanelRight,
250 (DockPlacement::Right, false) => IconName::PanelRightOpen,
251 (DockPlacement::Bottom, true) => IconName::PanelBottom,
252 (DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
253 (DockPlacement::Center, _) => return None,
254 };
255
256 let area = self.shared.area().clone();
257 Some(
258 Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
259 .icon(icon)
260 .xsmall()
261 .ghost()
262 .tab_stop(false)
263 .tooltip(match is_open {
264 true => t!("Dock.Collapse"),
265 false => t!("Dock.Expand"),
266 })
267 .on_click(move |_, window, cx| {
268 _ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
269 }),
270 )
271 }
272
273 fn render_toolbar(
276 &self,
277 group: &TabGroupContext,
278 window: &mut Window,
279 cx: &mut App,
280 ) -> impl IntoElement {
281 if group.is_collapsed() {
282 return div();
283 }
284
285 let zoomed = group.is_zoomed();
286 let handle = group.active_panel().and_then(PanelHandle::of);
287 let control = zoom_control(group, cx);
288 let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
289 let menu_zoom = control.is_some_and(|control| control.menu_visible());
290 let closable = group.is_closable();
291 let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
292 let panel = handle.map(|handle| handle.panel());
293
294 h_flex()
295 .gap_1()
296 .occlude()
297 .when_some(buttons, |this, buttons| {
298 this.children(
299 buttons
300 .into_iter()
301 .map(|button| button.xsmall().ghost().tab_stop(false)),
302 )
303 })
304 .when_some(
305 match (zoomed, toolbar_zoom) {
306 (true, _) => Some(("zoom-out", IconName::Minimize, t!("Dock.Zoom Out"))),
307 (false, true) => Some(("zoom-in", IconName::Maximize, t!("Dock.Zoom In"))),
308 (false, false) => None,
309 },
310 |this, (id, icon, tooltip)| {
311 this.child(
312 Button::new(id)
313 .icon(icon)
314 .xsmall()
315 .ghost()
316 .tab_stop(false)
317 .tooltip_with_action(tooltip, &ToggleZoom, None)
318 .selected(zoomed)
319 .debug_selector(|| ZOOM_CONTROL_SELECTOR.to_string())
324 .on_click({
325 let group = group.clone();
326 move |_, window, cx| group.toggle_zoom(window, cx)
327 }),
328 )
329 },
330 )
331 .child(
332 Button::new("menu")
333 .icon(IconName::Ellipsis)
334 .xsmall()
335 .ghost()
336 .tab_stop(false)
337 .dropdown_menu(move |menu, window, cx| {
338 menu.when_some(panel.clone(), |menu, panel| {
339 panel.dropdown_menu(menu, window, cx)
340 })
341 .separator()
342 .menu_with_disabled(
343 match zoomed {
344 true => t!("Dock.Zoom Out"),
345 false => t!("Dock.Zoom In"),
346 },
347 Box::new(ToggleZoom),
348 !menu_zoom,
349 )
350 .when(closable, |menu| {
351 menu.separator()
352 .menu(t!("Dock.Close"), Box::new(ClosePanel))
353 })
354 })
355 .anchor(Anchor::TopRight),
356 )
357 }
358
359 fn render_title(
361 &self,
362 group: &TabGroupContext,
363 ix: usize,
364 window: &mut Window,
365 cx: &mut App,
366 ) -> AnyElement {
367 let panel = &group.panels()[ix];
368 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
369 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
370 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
371 let has_leading = left_button.is_some() || bottom_button.is_some();
372 let handle = PanelHandle::of(panel);
373 let title_style = handle.and_then(|handle| handle.title_style(cx));
374 let drag = tab_drag(group, ix, cx);
375
376 h_flex()
377 .justify_between()
378 .h(px(30.))
379 .py_2()
380 .pl_3()
381 .pr_2()
382 .when(left_button.is_some(), |this| this.pl_2())
383 .when(right_button.is_some(), |this| this.pr_2())
384 .when_some(title_style, |this, style| {
385 this.bg(style.background).text_color(style.foreground)
386 })
387 .when(has_leading, |this| {
388 this.child(
389 h_flex()
390 .flex_shrink_0()
391 .mr_1()
392 .gap_1()
393 .children(left_button)
394 .children(bottom_button),
395 )
396 })
397 .child(
398 div()
399 .id("tab")
400 .flex_1()
401 .min_w_16()
402 .overflow_hidden()
403 .text_ellipsis()
404 .whitespace_nowrap()
405 .child(panel_title(panel, window, cx))
406 .when_some(drag, |this, drag| {
407 this.on_drag(drag, {
408 let panel = panel.clone();
409 move |drag, offset, _, cx| {
410 cx.stop_propagation();
411 drag.set_drag_offset(offset);
412 drag.set_preview_size(DRAG_PREVIEW_SIZE);
413 cx.new(|_| DragPanelPreview {
414 panel: panel.clone(),
415 })
416 }
417 })
418 }),
419 )
420 .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
421 .child(
422 h_flex()
423 .flex_shrink_0()
424 .ml_1()
425 .gap_1()
426 .child(self.render_toolbar(group, window, cx))
427 .children(right_button),
428 )
429 .into_any_element()
430 }
431
432 fn render_tabs(
434 &self,
435 group: &TabGroupContext,
436 window: &mut Window,
437 cx: &mut App,
438 ) -> AnyElement {
439 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
440 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
441 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
442 let has_leading = left_button.is_some() || bottom_button.is_some();
443 let is_bottom_dock = bottom_button.is_some();
444 let collapsed = group.is_collapsed();
445
446 let droppable = group.is_droppable();
447 let tabs_count = group.panels().len();
448 let active_ix = group.active_ix();
449 let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
450 let visible: Vec<usize> = group
451 .panels()
452 .iter()
453 .enumerate()
454 .filter(|(_, panel)| panel.visible(cx))
455 .map(|(ix, _)| ix)
456 .collect();
457 let displayed_ix = displayed.and_then(|displayed| {
458 group
459 .panels()
460 .iter()
461 .position(|panel| panel.panel_id(cx) == displayed)
462 });
463
464 if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix) {
467 if let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix) {
468 self.scroll_handle.scroll_to_item(visible_ix);
469 }
470 }
471
472 TabBar::new("tab-bar")
473 .track_scroll(&self.scroll_handle)
474 .when(has_leading, |this| {
475 this.prefix(
476 h_flex()
477 .items_center()
478 .top_0()
479 .right(-px(1.))
481 .border_r_1()
482 .border_b_1()
483 .h_full()
484 .border_color(cx.theme().border)
485 .bg(cx.theme().tokens.tab_bar)
486 .px_2()
487 .children(left_button)
488 .children(bottom_button),
489 )
490 })
491 .children(
492 visible
493 .into_iter()
494 .map(|ix| {
495 let panel = &group.panels()[ix];
496 let handle = PanelHandle::of(panel);
497 let drag = tab_drag(group, ix, cx);
498
499 Tab::new()
500 .ix(ix)
501 .debug_selector(move || format!("{TAB_SELECTOR}-{ix}"))
502 .tab_bar_prefix(has_leading)
503 .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
504 Some(tab_name) => this.child(tab_name),
505 None => this.child(panel_title(panel, window, cx)),
506 })
507 .when(
513 !collapsed
514 && self.shared.close_button_visible.get()
515 && group.is_panel_closable(panel.panel_id(cx), cx),
516 |this| {
517 this.suffix(
518 Button::new(("close-tab", ix))
519 .icon(IconName::Close)
520 .xsmall()
521 .custom(
527 ButtonCustomVariant::new(cx)
528 .foreground(cx.theme().secondary_foreground)
529 .hover(*cx.theme().tokens.secondary_hover)
530 .active(*cx.theme().tokens.secondary_active),
531 )
532 .ml(-px(8.))
539 .mr_2()
540 .tab_stop(false)
541 .debug_selector(|| CLOSE_BUTTON_SELECTOR.to_string())
542 .on_click({
543 let group = group.clone();
544 let panel_id = panel.panel_id(cx);
545 move |_, window, cx| {
546 cx.stop_propagation();
547 group.close(panel_id, window, cx);
548 }
549 }),
550 )
551 },
552 )
553 .selected(!collapsed && Some(ix) == displayed_ix)
559 .on_click({
560 let group = group.clone();
561 let area = self.shared.area().clone();
562 let last_active_ix = self.last_active_ix.clone();
563 move |_, window, cx| {
564 last_active_ix.set(Some(ix));
567 group.select_tab(ix, window, cx);
568
569 if is_bottom_dock && collapsed {
572 _ = area.update(cx, |area, cx| {
573 area.toggle_dock(DockPlacement::Bottom, window, cx)
574 });
575 }
576 }
577 })
578 .when(!collapsed, |this| {
581 this.when_some(drag, |this, drag| {
582 this.on_drag(drag, {
583 let panel = panel.clone();
584 move |drag, offset, _, cx| {
585 cx.stop_propagation();
586 drag.set_drag_offset(offset);
587 drag.set_preview_size(DRAG_PREVIEW_SIZE);
588 cx.new(|_| DragPanelPreview {
589 panel: panel.clone(),
590 })
591 }
592 })
593 })
594 .when(droppable, |this| {
595 this.drag_over::<DragPanel>(|this, _, _, cx| {
596 this.rounded_l_none()
597 .border_l_2()
598 .border_r_0()
599 .border_color(cx.theme().drag_border)
600 })
601 .on_drop({
602 let group = group.clone();
603 move |drag: &DragPanel, window, cx| {
604 group.drop_panel(
605 drag.clone(),
606 Some(ix),
607 true,
608 window,
609 cx,
610 );
611 }
612 })
613 .drag_over::<AnyDrag>(|this, _, _, cx| {
614 this.rounded_l_none()
615 .border_l_2()
616 .border_r_0()
617 .border_color(cx.theme().drag_border)
618 })
619 .on_drop({
620 let group = group.clone();
621 move |item: &AnyDrag, window, cx| {
622 group.drop_item(item.clone(), None, window, cx);
623 }
624 })
625 })
626 })
627 })
628 .collect::<Vec<_>>(),
629 )
630 .last_empty_space(
631 div()
633 .id("tab-bar-empty-space")
634 .h_full()
635 .flex_grow_1()
636 .min_w_16()
637 .when(droppable, |this| {
638 this.drag_over::<DragPanel>(|this, _, _, cx| {
639 this.bg(cx.theme().tokens.drop_target)
640 })
641 .on_drop({
642 let group = group.clone();
643 let node = group.node();
644 move |drag: &DragPanel, window, cx| {
645 let ix = (drag.source() == node).then(|| tabs_count - 1);
649 group.drop_panel(drag.clone(), ix, false, window, cx);
650 }
651 })
652 .drag_over::<AnyDrag>(|this, _, _, cx| {
653 this.bg(cx.theme().tokens.drop_target)
654 })
655 .on_drop({
656 let group = group.clone();
657 move |item: &AnyDrag, window, cx| {
658 group.drop_item(item.clone(), None, window, cx);
659 }
660 })
661 }),
662 )
663 .when(!collapsed, |this| {
664 this.suffix(
665 h_flex()
666 .items_center()
667 .top_0()
668 .right_0()
669 .border_l_1()
670 .border_b_1()
671 .h_full()
672 .border_color(cx.theme().border)
673 .bg(cx.theme().tokens.tab_bar)
674 .px_2()
675 .gap_1()
676 .children(
677 group
678 .active_panel()
679 .and_then(PanelHandle::of)
680 .and_then(|handle| handle.title_suffix(window, cx)),
681 )
682 .child(self.render_toolbar(group, window, cx))
683 .children(right_button),
684 )
685 })
686 .into_any_element()
687 }
688}
689
690impl TabGroupRenderer for TabGroupSkin {
691 fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
692 let control = zoom_control(group, cx);
693
694 div()
697 .id("tab-panel")
698 .bg(cx.theme().tokens.background)
699 .when(!group.is_collapsed(), |this| {
702 this.on_action({
703 let group = group.clone();
704 move |_: &ToggleZoom, window, cx| {
705 if !group.is_zoomed() && control.is_none() {
711 return;
712 }
713 group.toggle_zoom(window, cx);
714 }
715 })
716 .on_action({
717 let group = group.clone();
718 move |_: &ClosePanel, window, cx| {
719 let Some(panel) = group.active_panel() else {
720 return;
721 };
722 let panel = panel.panel_id(cx);
723 group.close(panel, window, cx);
724 }
725 })
726 })
727 }
728
729 fn content_frame(
730 &self,
731 group: &TabGroupContext,
732 _: &mut Window,
733 cx: &mut App,
734 ) -> Stateful<Div> {
735 let padded = group.panels().len() > 1
736 && group
737 .active_panel()
738 .and_then(PanelHandle::of)
739 .is_none_or(|handle| handle.inner_padding(cx));
740
741 div().id("active-panel").when(padded, |this| this.pt_2())
744 }
745
746 fn render_tab_bar(
747 &self,
748 group: &TabGroupContext,
749 window: &mut Window,
750 cx: &mut App,
751 ) -> AnyElement {
752 let visible: Vec<usize> = group
753 .panels()
754 .iter()
755 .enumerate()
756 .filter(|(_, panel)| panel.visible(cx))
757 .map(|(ix, _)| ix)
758 .collect();
759
760 match visible.as_slice() {
761 [] => Empty.into_any_element(),
762 [ix] if self.shared.panel_style() == PanelStyle::Auto => {
763 let panel = &group.panels()[*ix];
765 if PanelHandle::of(panel).is_some_and(|handle| !handle.title_bar(cx)) {
766 return Empty.into_any_element();
767 }
768 self.render_title(group, *ix, window, cx)
769 }
770 _ => self.render_tabs(group, window, cx),
771 }
772 }
773
774 fn render_active_panel(
775 &self,
776 panel: AnyView,
777 group: &TabGroupContext,
778 _: &mut Window,
779 _: &mut App,
780 ) -> AnyElement {
781 if group.is_collapsed() {
782 return Empty.into_any_element();
783 }
784
785 div()
786 .id("tab-content")
787 .overflow_y_scroll()
788 .overflow_x_hidden()
789 .flex_1()
790 .child(panel.cached(StyleRefinement::default().absolute().size_full()))
791 .into_any_element()
792 }
793
794 fn render_drop_indicator(
795 &self,
796 indicator: DropIndicator,
797 window: &mut Window,
798 cx: &mut App,
799 ) -> Option<AnyElement> {
800 let to = indicator.to();
801 let id = "drop-placeholder";
807 let placeholder_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
808 let left = spring((id, "left"), to.origin().x, placeholder_spring, window, cx);
809 let top = spring((id, "top"), to.origin().y, placeholder_spring, window, cx);
810 let width = spring(
811 (id, "width"),
812 to.size().width,
813 placeholder_spring,
814 window,
815 cx,
816 );
817 let height = spring(
818 (id, "height"),
819 to.size().height,
820 placeholder_spring,
821 window,
822 cx,
823 );
824
825 Some(
826 div()
827 .absolute()
828 .bg(cx.theme().tokens.drop_target)
829 .left(left)
830 .top(top)
831 .w(width)
832 .h(height)
833 .into_any_element(),
834 )
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use std::{
841 cell::{Cell, RefCell},
842 sync::Arc,
843 };
844
845 use gpui::{
846 Entity, EventEmitter, FocusHandle, Focusable, Modifiers, MouseButton, Pixels,
847 TestAppContext, VisualTestContext,
848 };
849 use gpui_base::dock::{DockArea, DockAreaRenderer, DockLayout, DockPlacement, PanelEvent};
850
851 use super::*;
852 use crate::{
853 ElementExt as _,
854 dock::{
855 DockSkin, Panel, panel_handle,
856 test_support::{HideableProbe, MeasuredProbe},
857 },
858 };
859
860 struct Probe {
861 focus_handle: FocusHandle,
862 }
863
864 impl Probe {
865 fn new(cx: &mut App) -> Entity<Self> {
866 cx.new(|cx| Self {
867 focus_handle: cx.focus_handle(),
868 })
869 }
870 }
871
872 impl gpui_base::dock::Panel for Probe {
873 fn panel_name(&self) -> &'static str {
874 "Probe"
875 }
876 }
877
878 impl Panel for Probe {}
879 impl EventEmitter<PanelEvent> for Probe {}
880
881 impl Focusable for Probe {
882 fn focus_handle(&self, _: &App) -> FocusHandle {
883 self.focus_handle.clone()
884 }
885 }
886
887 impl Render for Probe {
888 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
889 Empty
890 }
891 }
892
893 #[derive(Default)]
896 struct Recorded {
897 draggable: Vec<bool>,
899 }
900
901 struct Recorder {
902 log: Rc<RefCell<Recorded>>,
903 }
904
905 impl TabGroupRenderer for Recorder {
906 fn render_tab_bar(
907 &self,
908 group: &TabGroupContext,
909 _: &mut Window,
910 cx: &mut App,
911 ) -> AnyElement {
912 let mut log = self.log.borrow_mut();
913 for ix in 0..group.panels().len() {
914 log.draggable.push(tab_drag(group, ix, cx).is_some());
915 }
916 Empty.into_any_element()
917 }
918 }
919
920 impl DockAreaRenderer for Recorder {
921 fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
922 div().id("recorder").size_full()
923 }
924
925 fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
926 Rc::new(Recorder {
927 log: self.log.clone(),
928 })
929 }
930 }
931
932 fn recording_area(
933 cx: &mut TestAppContext,
934 ) -> (
935 Entity<DockArea>,
936 Rc<RefCell<Recorded>>,
937 &mut VisualTestContext,
938 ) {
939 cx.update(|cx| {
940 crate::init(cx);
941 });
942 let log = Rc::new(RefCell::new(Recorded::default()));
943 let renderer = Rc::new(Recorder { log: log.clone() });
944 let (area, cx) = cx.add_window_view(|window, cx| {
945 DockArea::new("skin", None, window, cx).with_renderer(renderer)
946 });
947 (area, log, cx)
948 }
949
950 #[gpui::test]
954 fn the_last_group_in_a_dock_offers_no_drag(cx: &mut TestAppContext) {
955 let (area, log, cx) = recording_area(cx);
956
957 cx.update(|window, cx| {
958 let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
959 area.update(cx, |area, cx| area.set_center(layout, window, cx));
960 });
961 cx.run_until_parked();
962 log.borrow_mut().draggable.clear();
963 cx.update(|window, cx| window.draw(cx).clear(cx));
964
965 assert_eq!(
966 log.borrow().draggable,
967 vec![false],
968 "the only visible panel in the dock has nowhere to go, so its tab \
969 must not start a drag"
970 );
971 }
972
973 #[gpui::test]
974 fn a_group_beside_another_offers_a_drag(cx: &mut TestAppContext) {
975 let (area, log, cx) = recording_area(cx);
976
977 cx.update(|window, cx| {
978 let layout = DockLayout::h_split()
979 .child(
980 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
981 None,
982 )
983 .child(
984 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
985 None,
986 );
987 area.update(cx, |area, cx| area.set_center(layout, window, cx));
988 });
989 cx.run_until_parked();
990 log.borrow_mut().draggable.clear();
991 cx.update(|window, cx| window.draw(cx).clear(cx));
992
993 assert_eq!(
994 log.borrow().draggable,
995 vec![true, true],
996 "each group has somewhere to go, so both tabs start a drag"
997 );
998 }
999
1000 struct ToolbarZoomProbe {
1003 focus_handle: FocusHandle,
1004 }
1005
1006 impl ToolbarZoomProbe {
1007 fn new(cx: &mut App) -> Entity<Self> {
1008 cx.new(|cx| Self {
1009 focus_handle: cx.focus_handle(),
1010 })
1011 }
1012 }
1013
1014 impl gpui_base::dock::Panel for ToolbarZoomProbe {
1015 fn panel_name(&self) -> &'static str {
1016 "ToolbarZoomProbe"
1017 }
1018 }
1019
1020 impl Panel for ToolbarZoomProbe {
1021 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1022 Some(PanelControl::Toolbar)
1023 }
1024 }
1025
1026 impl EventEmitter<PanelEvent> for ToolbarZoomProbe {}
1027
1028 impl Focusable for ToolbarZoomProbe {
1029 fn focus_handle(&self, _: &App) -> FocusHandle {
1030 self.focus_handle.clone()
1031 }
1032 }
1033
1034 impl Render for ToolbarZoomProbe {
1035 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1036 Empty
1037 }
1038 }
1039
1040 struct NoControlProbe {
1043 focus_handle: FocusHandle,
1044 }
1045
1046 impl NoControlProbe {
1047 fn new(cx: &mut App) -> Entity<Self> {
1048 cx.new(|cx| Self {
1049 focus_handle: cx.focus_handle(),
1050 })
1051 }
1052 }
1053
1054 impl gpui_base::dock::Panel for NoControlProbe {
1055 fn panel_name(&self) -> &'static str {
1056 "NoControlProbe"
1057 }
1058 }
1059
1060 impl Panel for NoControlProbe {
1061 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1062 None
1063 }
1064 }
1065
1066 impl EventEmitter<PanelEvent> for NoControlProbe {}
1067
1068 impl Focusable for NoControlProbe {
1069 fn focus_handle(&self, _: &App) -> FocusHandle {
1070 self.focus_handle.clone()
1071 }
1072 }
1073
1074 impl Render for NoControlProbe {
1075 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1076 Empty
1077 }
1078 }
1079
1080 struct UnzoomableProbe {
1083 focus_handle: FocusHandle,
1084 }
1085
1086 impl gpui_base::dock::Panel for UnzoomableProbe {
1087 fn panel_name(&self) -> &'static str {
1088 "UnzoomableProbe"
1089 }
1090
1091 fn zoomable(&self, _: &App) -> bool {
1092 false
1093 }
1094 }
1095
1096 impl Panel for UnzoomableProbe {
1097 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1098 Some(PanelControl::Toolbar)
1099 }
1100 }
1101
1102 impl EventEmitter<PanelEvent> for UnzoomableProbe {}
1103
1104 impl Focusable for UnzoomableProbe {
1105 fn focus_handle(&self, _: &App) -> FocusHandle {
1106 self.focus_handle.clone()
1107 }
1108 }
1109
1110 impl Render for UnzoomableProbe {
1111 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1112 Empty
1113 }
1114 }
1115
1116 fn drew_zoom_control(
1123 cx: &mut TestAppContext,
1124 panel: impl FnOnce(&mut App) -> Arc<dyn gpui_base::dock::PanelView>,
1125 ) -> bool {
1126 cx.update(|cx| {
1127 crate::init(cx);
1128 });
1129 let (area, cx) = cx.add_window_view(|window, cx| {
1130 let skin = DockSkin::new(cx);
1131 DockArea::new("skin", None, window, cx).with_renderer(skin)
1132 });
1133
1134 cx.update(|window, cx| {
1135 let layout = DockLayout::tabs().panel_view(panel(cx), cx);
1136 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1137 });
1138 cx.run_until_parked();
1139 cx.update(|window, cx| window.draw(cx).clear(cx));
1140 cx.debug_bounds(ZOOM_CONTROL_SELECTOR).is_some()
1141 }
1142
1143 #[gpui::test]
1147 fn a_panel_base_will_not_zoom_gets_no_zoom_control(cx: &mut TestAppContext) {
1148 let drew = drew_zoom_control(cx, |cx| {
1149 panel_handle(cx.new(|cx| UnzoomableProbe {
1150 focus_handle: cx.focus_handle(),
1151 }))
1152 });
1153
1154 assert!(
1155 !drew,
1156 "the panel names a place for the control, but base refuses the zoom"
1157 );
1158 }
1159
1160 #[gpui::test]
1164 fn a_zoomable_panel_gets_its_zoom_control(cx: &mut TestAppContext) {
1165 let drew = drew_zoom_control(cx, |cx| panel_handle(ToolbarZoomProbe::new(cx)));
1166
1167 assert!(
1168 drew,
1169 "a zoomable panel asking for a toolbar control gets one"
1170 );
1171 }
1172
1173 #[gpui::test]
1183 fn the_centre_and_the_bottom_dock_share_the_column(cx: &mut TestAppContext) {
1184 cx.update(|cx| {
1185 crate::init(cx);
1186 });
1187 let centre = Rc::new(Cell::new(px(0.)));
1188 let bottom = Rc::new(Cell::new(px(0.)));
1189 let (area, cx) = cx.add_window_view(|window, cx| {
1190 let skin = DockSkin::new(cx);
1191 DockArea::new("skin", None, window, cx).with_renderer(skin)
1192 });
1193
1194 let (centre_probe, bottom_probe) = (centre.clone(), bottom.clone());
1195 cx.update(|window, cx| {
1196 let centre_panel = MeasuredProbe::new(centre_probe, cx);
1197 let bottom_panel = MeasuredProbe::new(bottom_probe, cx);
1198 area.update(cx, |area, cx| {
1199 area.set_center(
1203 DockLayout::v_split().child(
1204 DockLayout::h_split().child(
1205 DockLayout::tabs().panel_view(panel_handle(centre_panel), cx),
1206 None,
1207 ),
1208 None,
1209 ),
1210 window,
1211 cx,
1212 );
1213 area.set_dock(
1214 DockPlacement::Bottom,
1215 DockLayout::tabs().panel_view(panel_handle(bottom_panel), cx),
1216 window,
1217 cx,
1218 );
1219 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
1220 });
1221 });
1222 cx.run_until_parked();
1223 cx.update(|window, cx| window.draw(cx).clear(cx));
1224
1225 let window_height = cx.update(|window, _| window.viewport_size().height);
1226 assert!(
1227 centre.get() > px(0.),
1228 "the centre panel must receive height; it got {:?}",
1229 centre.get()
1230 );
1231 assert!(
1232 bottom.get() > px(0.),
1233 "the bottom dock's panel must receive height; it got {:?}",
1234 bottom.get()
1235 );
1236 assert!(
1237 centre.get() < window_height - px(150.),
1238 "the centre must give the 200px bottom dock its share; the centre \
1239 got {:?} of {window_height:?}",
1240 centre.get()
1241 );
1242 }
1243
1244 #[gpui::test]
1259 fn a_split_fills_its_container_whichever_slots_are_hidden(cx: &mut TestAppContext) {
1260 cx.update(|cx| {
1261 crate::init(cx);
1262 });
1263 let heights: Vec<Rc<Cell<Pixels>>> = (0..3).map(|_| Rc::new(Cell::new(px(0.)))).collect();
1264 let (area, cx) = cx.add_window_view(|window, cx| {
1265 let skin = DockSkin::new(cx);
1266 DockArea::new("skin", None, window, cx).with_renderer(skin)
1267 });
1268
1269 let slots = heights.clone();
1270 let probes = cx.update(|window, cx| {
1271 let probes: Vec<_> = slots
1272 .iter()
1273 .map(|height| HideableProbe::new(height.clone(), cx))
1274 .collect();
1275 area.update(cx, |area, cx| {
1276 area.set_dock(
1277 DockPlacement::Right,
1278 probes.iter().zip([260., 320., 200.]).fold(
1279 DockLayout::v_split(),
1280 |split, (probe, size)| {
1281 split.child(
1282 DockLayout::tabs().panel_view(panel_handle(probe.clone()), cx),
1283 Some(px(size)),
1284 )
1285 },
1286 ),
1287 window,
1288 cx,
1289 );
1290 area.set_dock_size(DockPlacement::Right, px(380.), window, cx);
1291 });
1292 probes
1293 });
1294 cx.run_until_parked();
1295 let draw = |cx: &mut VisualTestContext| {
1296 cx.update(|window, _| window.refresh());
1297 cx.update(|window, cx| window.draw(cx).clear(cx));
1298 cx.update(|window, cx| window.draw(cx).clear(cx));
1299 };
1300 draw(cx);
1301
1302 let dock_height = cx.update(|window, _| window.viewport_size().height);
1303 let drawn: Pixels = heights.iter().map(|height| height.get()).sum();
1307 let bar = (dock_height - drawn) / 3.;
1308 assert!(
1309 bar > px(0.) && bar < px(60.),
1310 "the three slots fill the dock to begin with, one tab bar each; \
1311 that leaves {bar:?} per slot of {dock_height:?}"
1312 );
1313
1314 for hidden in 1..0b111u8 {
1317 let shown = (0..3).filter(|slot| hidden & (1 << slot) == 0);
1318 cx.update(|_, cx| {
1319 for (slot, probe) in probes.iter().enumerate() {
1320 heights[slot].set(px(-1.));
1323 probe.update(cx, |probe, cx| {
1324 probe.set_visible(hidden & (1 << slot) == 0, cx)
1325 });
1326 }
1327 });
1328 cx.run_until_parked();
1329 draw(cx);
1330
1331 let mut count = 0;
1332 let mut total = px(0.);
1333 for slot in shown {
1334 assert_ne!(
1335 heights[slot].get(),
1336 px(-1.),
1337 "hiding {hidden:03b}: slot {slot} is shown and must draw"
1338 );
1339 count += 1;
1340 total += heights[slot].get();
1341 }
1342 let empty = dock_height - total - bar * count as f32;
1343 assert!(
1344 empty.abs() < px(1.),
1345 "hiding {hidden:03b}: the drawn slots must take the hidden \
1346 ones' space between them; they left {empty:?} of \
1347 {dock_height:?} empty"
1348 );
1349 }
1350 }
1351
1352 #[gpui::test]
1356 fn the_zoom_action_reaches_the_group_through_the_skin(cx: &mut TestAppContext) {
1357 cx.update(|cx| {
1358 crate::init(cx);
1359 });
1360 let (area, cx) = cx.add_window_view(|window, cx| {
1361 let skin = DockSkin::new(cx);
1362 DockArea::new("skin", None, window, cx).with_renderer(skin)
1363 });
1364
1365 let panel = cx.update(|window, cx| {
1366 let panel = Probe::new(cx);
1367 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1368 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1369 panel
1370 });
1371 cx.run_until_parked();
1372 cx.update(|window, cx| {
1373 panel.read(cx).focus_handle(cx).focus(window, cx);
1374 });
1375 cx.run_until_parked();
1376
1377 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1378 cx.dispatch_action(ToggleZoom);
1379 cx.run_until_parked();
1380 assert_eq!(
1381 cx.read(|cx| area.read(cx).is_zoomed()),
1382 true,
1383 "the skin's frame is what carries the ToggleZoom handler"
1384 );
1385
1386 cx.dispatch_action(ToggleZoom);
1387 cx.run_until_parked();
1388 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1389 }
1390
1391 #[gpui::test]
1397 fn the_zoom_action_refuses_a_panel_that_offers_no_control(cx: &mut TestAppContext) {
1398 cx.update(|cx| {
1399 crate::init(cx);
1400 });
1401 let (area, cx) = cx.add_window_view(|window, cx| {
1402 let skin = DockSkin::new(cx);
1403 DockArea::new("skin", None, window, cx).with_renderer(skin)
1404 });
1405
1406 let panel = cx.update(|window, cx| {
1407 let panel = NoControlProbe::new(cx);
1408 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1409 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1410 panel
1411 });
1412 cx.run_until_parked();
1413 cx.update(|window, cx| {
1414 panel.read(cx).focus_handle(cx).focus(window, cx);
1415 });
1416 cx.run_until_parked();
1417
1418 cx.dispatch_action(ToggleZoom);
1419 cx.run_until_parked();
1420 assert_eq!(
1421 cx.read(|cx| area.read(cx).is_zoomed()),
1422 false,
1423 "no control means no zoom, however the zoom was asked for"
1424 );
1425 }
1426
1427 #[gpui::test]
1442 fn the_panel_content_region_gets_the_height_below_the_tab_bar(cx: &mut TestAppContext) {
1443 cx.update(|cx| {
1444 crate::init(cx);
1445 });
1446 let height = Rc::new(Cell::new(px(0.)));
1447 let (area, cx) = cx.add_window_view(|window, cx| {
1448 let skin = DockSkin::new(cx);
1449 DockArea::new("skin", None, window, cx).with_renderer(skin)
1450 });
1451
1452 let measured = height.clone();
1453 cx.update(|window, cx| {
1454 let panel = MeasuredProbe::new(measured, cx);
1455 let layout = DockLayout::tabs().panel_view(panel_handle(panel), cx);
1456 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1457 });
1458 cx.run_until_parked();
1459 cx.update(|window, cx| window.draw(cx).clear(cx));
1460
1461 let window_height = cx.update(|window, _| window.viewport_size().height);
1462 let content = height.get();
1463 assert!(
1464 content > px(0.),
1465 "the panel must receive height; it got {content:?} in a {window_height:?} window"
1466 );
1467 assert!(
1470 content > window_height - px(60.),
1471 "the panel should fill what the tab bar leaves; it got {content:?} \
1472 of {window_height:?}"
1473 );
1474 }
1475
1476 #[gpui::test]
1483 fn a_collapsed_dock_ignores_the_zoom_action(cx: &mut TestAppContext) {
1484 cx.update(|cx| {
1485 crate::init(cx);
1486 });
1487 let (area, cx) = cx.add_window_view(|window, cx| {
1488 let skin = DockSkin::new(cx);
1489 DockArea::new("skin", None, window, cx).with_renderer(skin)
1490 });
1491
1492 let panel = cx.update(|window, cx| {
1493 let panel = Probe::new(cx);
1494 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1495 area.update(cx, |area, cx| {
1496 area.set_dock(DockPlacement::Bottom, layout, window, cx);
1497 area.toggle_dock(DockPlacement::Bottom, window, cx);
1498 });
1499 panel
1500 });
1501 cx.run_until_parked();
1502 cx.update(|window, cx| {
1503 panel.read(cx).focus_handle(cx).focus(window, cx);
1504 });
1505 cx.run_until_parked();
1506
1507 cx.dispatch_action(ToggleZoom);
1508 cx.run_until_parked();
1509 assert_eq!(
1510 cx.read(|cx| area.read(cx).is_zoomed()),
1511 false,
1512 "a collapsed group installs no action handler"
1513 );
1514 }
1515
1516 #[gpui::test]
1519 fn a_panel_without_a_title_bar_gets_the_whole_group(cx: &mut TestAppContext) {
1520 struct Chromeless {
1521 focus_handle: FocusHandle,
1522 height: Rc<Cell<Pixels>>,
1523 }
1524
1525 impl gpui_base::dock::Panel for Chromeless {
1526 fn panel_name(&self) -> &'static str {
1527 "Chromeless"
1528 }
1529 }
1530
1531 impl Panel for Chromeless {
1532 fn title_bar(&self, _: &App) -> bool {
1533 false
1534 }
1535 }
1536
1537 impl EventEmitter<PanelEvent> for Chromeless {}
1538
1539 impl Focusable for Chromeless {
1540 fn focus_handle(&self, _: &App) -> FocusHandle {
1541 self.focus_handle.clone()
1542 }
1543 }
1544
1545 impl Render for Chromeless {
1546 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1547 let height = self.height.clone();
1548 div()
1549 .size_full()
1550 .on_prepaint(move |bounds, _, _| height.set(bounds.size.height))
1551 }
1552 }
1553
1554 cx.update(|cx| crate::init(cx));
1555
1556 let measure = |cx: &mut TestAppContext, chromeless: bool| -> Pixels {
1557 let height = Rc::new(Cell::new(px(0.)));
1558 let (area, cx) = cx.add_window_view(|window, cx| {
1559 let skin = DockSkin::new(cx);
1560 DockArea::new("skin", None, window, cx).with_renderer(skin)
1561 });
1562 let measured = height.clone();
1563 cx.update(|window, cx| {
1564 let panel: Arc<dyn gpui_base::dock::PanelView> = if chromeless {
1565 panel_handle(cx.new(|cx| Chromeless {
1566 focus_handle: cx.focus_handle(),
1567 height: measured,
1568 }))
1569 } else {
1570 panel_handle(MeasuredProbe::new(measured, cx))
1571 };
1572 let layout = DockLayout::tabs().panel_view(panel, cx);
1573 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1574 });
1575 cx.run_until_parked();
1576 cx.update(|window, cx| window.draw(cx).clear(cx));
1577 height.get()
1578 };
1579
1580 let with_title = measure(cx, false);
1581 let without = measure(cx, true);
1582 assert!(with_title > px(0.), "the probe must have been drawn");
1583 assert_eq!(
1584 without,
1585 with_title + px(30.),
1586 "the 30px the title bar took go to the panel instead"
1587 );
1588 }
1589
1590 struct TabProbe {
1592 focus_handle: FocusHandle,
1593 closable: bool,
1594 activated: Rc<Cell<bool>>,
1595 }
1596
1597 impl TabProbe {
1598 fn new(closable: bool, cx: &mut App) -> Entity<Self> {
1599 cx.new(|cx| Self {
1600 focus_handle: cx.focus_handle(),
1601 closable,
1602 activated: Rc::new(Cell::new(false)),
1603 })
1604 }
1605 }
1606
1607 impl gpui_base::dock::Panel for TabProbe {
1608 fn panel_name(&self) -> &'static str {
1609 "TabProbe"
1610 }
1611
1612 fn closable(&self, _: &App) -> bool {
1613 self.closable
1614 }
1615
1616 fn set_active(&mut self, active: bool, _: &mut Window, _: &mut Context<Self>) {
1617 if active {
1618 self.activated.set(true);
1619 }
1620 }
1621 }
1622
1623 impl Panel for TabProbe {}
1624
1625 impl EventEmitter<PanelEvent> for TabProbe {}
1626
1627 impl Focusable for TabProbe {
1628 fn focus_handle(&self, _: &App) -> FocusHandle {
1629 self.focus_handle.clone()
1630 }
1631 }
1632
1633 impl Render for TabProbe {
1634 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1635 Empty
1636 }
1637 }
1638
1639 fn drew_close_button(cx: &mut TestAppContext, closable: bool, enabled: bool) -> bool {
1644 cx.update(|cx| crate::init(cx));
1645 let mut skin = None;
1646 let (area, cx) = cx.add_window_view(|window, cx| {
1647 let renderer = DockSkin::new(cx);
1648 skin = Some(renderer.clone());
1649 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1650 });
1651 let skin = skin.expect("skin constructed with the area");
1652 if enabled {
1653 cx.update(|_, cx| skin.set_close_button_visible(true, cx));
1654 }
1655 cx.update(|window, cx| {
1656 let under_test = TabProbe::new(closable, cx);
1657 let filler = TabProbe::new(false, cx);
1658 let layout = DockLayout::tabs()
1659 .panel_view(panel_handle(under_test), cx)
1660 .panel_view(panel_handle(filler), cx);
1661 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1662 });
1663 cx.run_until_parked();
1664 cx.update(|window, cx| window.draw(cx).clear(cx));
1665 let button = cx.debug_bounds(CLOSE_BUTTON_SELECTOR);
1666 if enabled && closable {
1667 assert_eq!(
1668 button.as_ref().map(|bounds| bounds.size.width),
1669 Some(px(20.)),
1670 "the close button keeps the standard XS hover target"
1671 );
1672 }
1673 button.is_some()
1674 }
1675
1676 #[gpui::test]
1678 fn a_closable_panel_gets_a_close_button(cx: &mut TestAppContext) {
1679 assert!(
1680 drew_close_button(cx, true, true),
1681 "a closable panel's tab must draw a close button"
1682 );
1683 }
1684
1685 #[gpui::test]
1687 fn a_non_closable_panel_gets_no_close_button(cx: &mut TestAppContext) {
1688 assert!(
1689 !drew_close_button(cx, false, true),
1690 "a panel that reports itself non-closable must draw no close button"
1691 );
1692 }
1693
1694 #[gpui::test]
1695 fn close_buttons_are_off_by_default(cx: &mut TestAppContext) {
1696 assert!(!drew_close_button(cx, true, false));
1697 }
1698
1699 #[gpui::test]
1700 fn close_button_visibility_updates_after_the_skin_setting_changes(cx: &mut TestAppContext) {
1701 cx.update(|cx| crate::init(cx));
1702 let mut skin = None;
1703 let (area, cx) = cx.add_window_view(|window, cx| {
1704 let renderer = DockSkin::new(cx);
1705 skin = Some(renderer.clone());
1706 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1707 });
1708 let skin = skin.expect("skin constructed with the area");
1709 cx.update(|window, cx| {
1710 let panel = TabProbe::new(true, cx);
1711 let filler = TabProbe::new(false, cx);
1712 area.update(cx, |area, cx| {
1713 area.set_center(
1714 DockLayout::tabs()
1715 .panel_view(panel_handle(panel), cx)
1716 .panel_view(panel_handle(filler), cx),
1717 window,
1718 cx,
1719 )
1720 });
1721 });
1722
1723 for (visible, expected) in [(false, false), (true, true), (false, false)] {
1724 cx.update(|_, cx| skin.set_close_button_visible(visible, cx));
1725 cx.run_until_parked();
1726 cx.update(|window, cx| window.draw(cx).clear(cx));
1727 assert_eq!(cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_some(), expected);
1728 }
1729 }
1730
1731 #[gpui::test]
1735 fn clicking_a_close_button_removes_only_that_tab(cx: &mut TestAppContext) {
1736 cx.update(|cx| crate::init(cx));
1737 let mut skin = None;
1738 let (area, cx) = cx.add_window_view(|window, cx| {
1739 let renderer = DockSkin::new(cx);
1740 skin = Some(renderer.clone());
1741 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1742 });
1743 cx.update(|_, cx| skin.unwrap().set_close_button_visible(true, cx));
1744
1745 let (closable_id, filler_id, activated) = cx.update(|window, cx| {
1746 let filler = TabProbe::new(false, cx);
1747 let closable = TabProbe::new(true, cx);
1748 let activated = closable.read(cx).activated.clone();
1749 let filler_handle = panel_handle(filler);
1750 let closable_handle = panel_handle(closable);
1751 let ids = (closable_handle.panel_id(cx), filler_handle.panel_id(cx));
1752 let layout = DockLayout::tabs()
1755 .panel_view(filler_handle, cx)
1756 .panel_view(closable_handle, cx);
1757 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1758 (ids.0, ids.1, activated)
1759 });
1760 cx.run_until_parked();
1761 cx.update(|window, cx| window.draw(cx).clear(cx));
1762
1763 assert!(
1764 cx.update(|_, cx| area.read(cx).panel(closable_id).is_some()),
1765 "the closable tab starts owned by the area"
1766 );
1767
1768 let button = cx
1769 .debug_bounds(CLOSE_BUTTON_SELECTOR)
1770 .expect("the non-active closable tab draws a close button");
1771 cx.simulate_mouse_down(button.center(), MouseButton::Left, Modifiers::none());
1772 cx.simulate_mouse_up(button.center(), MouseButton::Left, Modifiers::none());
1773 cx.run_until_parked();
1774
1775 assert!(
1776 cx.update(|_, cx| area.read(cx).panel(closable_id).is_none()),
1777 "the close click removes exactly the tab it belongs to"
1778 );
1779 assert!(
1780 cx.update(|_, cx| area.read(cx).panel(filler_id).is_some()),
1781 "the displayed filler tab is untouched"
1782 );
1783 assert!(
1784 !activated.get(),
1785 "stop_propagation keeps the close click from also selecting the tab"
1786 );
1787 }
1788
1789 #[gpui::test]
1793 fn a_clicked_tab_is_revealed_by_the_tab_bar_not_snapped(cx: &mut TestAppContext) {
1794 cx.update(|cx| {
1795 crate::init(cx);
1796 cx.set_reduce_motion(false);
1797 });
1798 let (area, cx) = cx.add_window_view(|window, cx| {
1799 DockArea::new("skin", None, window, cx).with_renderer(DockSkin::new(cx))
1800 });
1801 cx.simulate_resize(gpui::size(px(300.), px(300.)));
1802 cx.update(|window, cx| {
1803 let layout = (0..8).fold(DockLayout::tabs(), |layout, _| {
1804 layout.panel_view(panel_handle(Probe::new(cx)), cx)
1805 });
1806 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1807 });
1808 cx.run_until_parked();
1809 cx.update(|window, cx| window.draw(cx).clear(cx));
1810
1811 let tab = |cx: &mut VisualTestContext, ix: usize| {
1813 cx.debug_bounds(format!("{TAB_SELECTOR}-{ix}").leak())
1814 .unwrap()
1815 };
1816 let first_left = |cx: &mut VisualTestContext| tab(cx, 0).left();
1817 let start = first_left(cx);
1818 let clipped = (0..8)
1821 .map(|ix| tab(cx, ix))
1822 .find(|tab| tab.right() > px(300.))
1823 .unwrap();
1824
1825 cx.simulate_click(
1826 clipped.origin + gpui::point(px(4.), px(16.)),
1827 Modifiers::none(),
1828 );
1829 cx.update(|window, cx| window.draw(cx).clear(cx));
1830 assert_eq!(first_left(cx), start, "the first frame has not snapped");
1831
1832 let mut lefts = Vec::new();
1833 for _ in 0..60 {
1834 cx.executor()
1835 .advance_clock(std::time::Duration::from_millis(16));
1836 cx.update(|window, cx| {
1837 window.simulate_next_frame(cx);
1838 window.draw(cx).clear(cx);
1839 });
1840 lefts.push(first_left(cx));
1841 }
1842 let end = *lefts.last().unwrap();
1843 assert!(end < start, "the strip scrolled");
1844 assert!(
1845 lefts.iter().any(|left| *left < start && *left > end),
1846 "the strip slid rather than jumped: {lefts:?}"
1847 );
1848 }
1849
1850 #[gpui::test]
1854 fn a_collapsed_group_draws_no_close_button(cx: &mut TestAppContext) {
1855 cx.update(|cx| crate::init(cx));
1856 let mut skin = None;
1857 let (area, cx) = cx.add_window_view(|window, cx| {
1858 let renderer = DockSkin::new(cx);
1859 skin = Some(renderer.clone());
1860 DockArea::new("skin", None, window, cx).with_renderer(renderer)
1861 });
1862 cx.update(|_, cx| skin.unwrap().set_close_button_visible(true, cx));
1863 cx.update(|window, cx| {
1866 let a = TabProbe::new(true, cx);
1867 let b = TabProbe::new(true, cx);
1868 let layout = DockLayout::tabs()
1869 .panel_view(panel_handle(a), cx)
1870 .panel_view(panel_handle(b), cx);
1871 area.update(cx, |area, cx| {
1872 area.set_dock(DockPlacement::Bottom, layout, window, cx);
1873 if !area.is_dock_open(DockPlacement::Bottom) {
1874 area.toggle_dock(DockPlacement::Bottom, window, cx);
1875 }
1876 });
1877 });
1878 cx.run_until_parked();
1879 cx.update(|window, cx| window.draw(cx).clear(cx));
1880 assert!(
1881 cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_some(),
1882 "an open group with two closable panels offers a close button"
1883 );
1884
1885 cx.update(|window, cx| {
1888 area.update(cx, |area, cx| {
1889 area.toggle_dock(DockPlacement::Bottom, window, cx)
1890 });
1891 });
1892 cx.run_until_parked();
1893 cx.update(|window, cx| window.draw(cx).clear(cx));
1894 assert!(
1895 cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_none(),
1896 "a collapsed group must not offer a close button"
1897 );
1898 }
1899}