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