Skip to main content

gpui_component/dock/
tab_panel.rs

1//! The gpui-component appearance for a tab group.
2//!
3//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
4//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
5//! visible is here: the tab bar, the toolbar, the ellipsis menu, the dock
6//! collapse affordances, the drop placeholder, and the styled drag preview.
7
8use 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
39/// Names the tab bar's zoom button in the debug-bounds map, so a test can ask
40/// a really-drawn frame whether the control was offered.
41const ZOOM_CONTROL_SELECTOR: &str = "dock-tab-bar-zoom-control";
42
43/// Debug-bounds selector for a tab's close (X) button, for tests.
44const CLOSE_BUTTON_SELECTOR: &str = "dock-tab-close-button";
45
46/// The size the styled drag preview occupies, reported to base so a drop
47/// placeholder knows where to fly in from.
48const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = gpui::size(px(96.), px(30.));
49
50/// The preview that follows the cursor while a panel is dragged.
51///
52/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
53/// appearance half, reintroduced here.
54pub 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
78/// A panel's title, or its registered name when it reached base without this
79/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
80pub(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    /// Panels already warned about. `panel_title` sits on the render path, so
95    /// an unguarded warning would repeat at frame rate and bury the very
96    /// signal it exists to give.
97    ///
98    /// Keyed by panel rather than a bare `Once` so a second wrongly installed
99    /// panel is still named, and a runtime set rather than a `debug_assert!`
100    /// so a release build says it too — the consequence is a shipped app whose
101    /// tabs are titleless, which is exactly when someone needs to be told.
102    /// Thread-local because rendering happens on one thread, so no lock is
103    /// needed.
104    static WARNED_UNWRAPPED: RefCell<HashSet<PanelId>> = RefCell::new(HashSet::new());
105}
106
107/// Say once, per panel, that a panel reached the skin without its
108/// presentation handle. Silent otherwise, and visual-only: the panel docks,
109/// drags and persists, it just has no title. The shorter method is the wrong
110/// one — `DockLayout::panel` and `DockArea::add_panel` accept a
111/// `gpui_component::dock::Panel` and store the bare entity — so this says
112/// which panel and what to call instead.
113fn 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
127/// Where the zoom affordance goes for the group's displayed panel, or `None`
128/// when there is none to offer.
129///
130/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
131/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
132/// whether zooming happens at all, and base refuses a zoom that fails it. The
133/// old dock had a single `zoomable() -> Option<PanelControl>` that could not
134/// disagree with itself; split across the seam it can, and a panel answering
135/// `zoomable() == false` with `zoom_control() == Some(Toolbar)` would
136/// otherwise draw a button that does nothing.
137fn 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
145/// The payload for dragging the tab at `ix` out of its group, or `None` when
146/// this group must not be rearranged.
147///
148/// The guard is the skin's. [`TabGroupContext::drag_panel`] answers for any
149/// tab in range whether or not the group may be rearranged, so a tab bar that
150/// forgets to ask [`TabGroupContext::is_draggable`] makes a group that has
151/// nowhere to go — a dock's last group, a locked dock — draggable anyway. The
152/// old dock asked the same question, spelled `state.draggable`.
153fn 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
160/// The left-most, top-most tab group in a container — where a left dock's
161/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
162fn 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
169/// The right-most, top-most tab group. A vertical split stacks its children,
170/// so its *first* child is the top one; a horizontal split's last child is the
171/// right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
172fn 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
183/// One tab group's appearance.
184///
185/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once per
186/// container — so the tab bar's scroll position belongs to the group whose
187/// tabs it scrolls.
188pub(crate) struct TabGroupSkin {
189    shared: Rc<SkinShared>,
190    scroll_handle: ScrollHandle,
191    /// The displayed tab the last frame drew, so a change scrolls the new tab
192    /// into view. The old dock recorded this at the moment of selection; the
193    /// group now owns selection, so the skin notices instead of being told.
194    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    /// Whether a dock's collapse affordance belongs in *this* group's tab bar,
207    /// and which way it points. `None` means this group draws none.
208    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        // A dock that does not exist is not collapsible, so this covers the
221        // old `left_dock.is_some()` test too.
222        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    /// The trailing controls: the panel's own buttons, the zoom affordance,
271    /// and the ellipsis menu.
272    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                            // Whether this button was drawn is the whole of
317                            // the `zoom_control` decision, and there is no
318                            // other way to ask a drawn tree about it. A no-op
319                            // outside test builds; see `debug_selector`.
320                            .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    /// The one-panel title bar: no tabs, just the title and the controls.
357    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    /// The full tab bar.
430    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        // Bring a newly displayed tab into view. The group owns selection now,
462        // so the skin notices the change rather than being told about it.
463        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 -1 for avoid border overlap with the first tab
477                        .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                            // Per-tab close (X) button. The gate mirrors
504                            // `TabGroup::close_panel`, plus `!collapsed`, since a
505                            // collapsed strip is a way back in, not a place to
506                            // close. Stops propagation so the click closes by id
507                            // without also selecting the tab.
508                            .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                                            // The regular ghost hover matches the
518                                            // inactive tab bar background in the
519                                            // default theme. Use a stronger theme
520                                            // surface while keeping a transparent
521                                            // idle background and no border.
522                                            .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                                            // The 20px XS button has a 12px icon:
529                                            // 4px inside plus 8px outside matches
530                                            // the label's 12px leading padding.
531                                            // Offset the label's own 12px right
532                                            // padding and the tab's 4px gap so the
533                                            // text-to-icon distance is also 12px.
534                                            .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                            // A collapsed group shows no tab as active: the
550                            // strip is a way back in, not a selection. The
551                            // comparison is against the panel on screen, not
552                            // the stored index: a hidden displayed tab falls
553                            // back to the first visible one.
554                            .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                                    // Clicking the strip of a collapsed bottom
562                                    // dock is how it is opened again.
563                                    if is_bottom_dock && collapsed {
564                                        _ = area.update(cx, |area, cx| {
565                                            area.toggle_dock(DockPlacement::Bottom, window, cx)
566                                        });
567                                    }
568                                }
569                            })
570                            // A collapsed group is a strip of tabs with no
571                            // content, so there is nothing to rearrange in it.
572                            .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                // Empty space so a panel can be moved past the last tab.
624                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                                // A panel dropped past its own last tab lands
638                                // in the final slot; one from elsewhere is
639                                // appended in the background.
640                                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        // The column, the fill and the clip are base's now, applied around
687        // this. What is left is the background and the two actions.
688        div()
689            .id("tab-panel")
690            .bg(cx.theme().tokens.background)
691            // A collapsed group is a strip of tabs with no content, and the
692            // actions act on content. The old dock gated them the same way.
693            .when(!group.is_collapsed(), |this| {
694                this.on_action({
695                    let group = group.clone();
696                    move |_: &ToggleZoom, window, cx| {
697                        // The affordance decides the control, so a panel that
698                        // offers none is not zoomed *in* by the keybinding
699                        // either. Zooming out is never refused: a panel that
700                        // stopped offering the control while zoomed would
701                        // otherwise strand the user with no way back.
702                        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        // The fill and the collapsed-group exception are base's; the padding
734        // is this skin's, and is the only reason this hook is implemented.
735        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                // A panel that draws its own chrome declines the title bar.
756                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        // The placeholder chases the drop it would land in. Its rect was
794        // previously replayed from the drag source on every epoch, so crossing
795        // several drop zones in one drag restarted the walk at each one; the
796        // springs carry it through instead, and the element no longer needs an
797        // outer frame to hold the destination while an inner one walks to it.
798        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    /// Draws nothing, but runs the real gates the skin's tab bar runs and
886    /// records what they decided for every group it was asked to draw.
887    #[derive(Default)]
888    struct Recorded {
889        /// One entry per tab: whether its tab would start a drag.
890        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    /// The gate carried forward from the old `TabPanel::render`, which wrapped
943    /// `on_drag` in `.when(state.draggable, ..)`. Base does not enforce it:
944    /// `TabGroupContext::drag_panel` answers for any tab in range.
945    #[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    /// A panel that allows zooming and asks for the control in the toolbar.
993    /// `Probe`'s default is `PanelControl::Menu`, which draws no button.
994    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    /// A panel that offers no zoom control but leaves base's `zoomable`
1033    /// default alone. Withholding the control is meant to be enough.
1034    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    /// A panel that says "never zoom" in base's half but still names a place
1073    /// for the control in this crate's half.
1074    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    /// Draw one panel through the real [`DockSkin`] and report whether its tab
1109    /// bar offered a zoom control.
1110    ///
1111    /// The real skin, not a recorder: the bug this guards is `render_toolbar`
1112    /// asking only half the question, so a test that calls `zoom_control`
1113    /// itself would pass with the bug in place.
1114    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    /// The two halves of the old `zoomable()` can now disagree, and base is
1136    /// the one that decides. A control drawn against base's refusal is dead:
1137    /// pressing it does nothing.
1138    #[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    /// The other half: a panel that allows zoom and asks for a toolbar control
1153    /// gets one drawn. Without this the test above would also pass a skin that
1154    /// never draws a zoom control at all.
1155    #[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    /// The centre and the bottom dock share the centre column, and both get
1166    /// height.
1167    ///
1168    /// `DockSkin::center_frame` is a flex column holding the centre's root
1169    /// split and the bottom dock, and the centre's split frame sits between
1170    /// them. This pins that the frame carries a size at all: strip
1171    /// `split_frame` of both `size_full` and `flex_1` and every panel here
1172    /// measures zero. It does not pin *which* of the two does the work —
1173    /// either alone passes.
1174    #[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                // A split inside a split, so the *nested* `split_frame` — the
1192                // one that sits inside a `resizable_panel` — is exercised too,
1193                // not only the centre's root.
1194                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    /// Whichever slots of a split are hidden, the drawn ones fill it.
1237    ///
1238    /// `render_node` pins every slot but one to a fixed size and lets the
1239    /// remaining one absorb whatever the container has spare. Picking that
1240    /// slot by tree position alone picks a hidden one whenever the trailing
1241    /// container's panels are all hidden — nothing draws there, nothing
1242    /// grows, and the split stops short of its frame, showing a band of the
1243    /// frame's own background under the last visible panel. Hiding a slot is
1244    /// an everyday event: a panel that is only meaningful for some symbols
1245    /// answers `visible` with `false` for the rest.
1246    ///
1247    /// Every subset is covered because the defect is positional: only the
1248    /// cases that hide the trailing slot fail, and a test that hid one fixed
1249    /// slot would pass against a fix that only special-cased that slot.
1250    #[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        // Each slot spends a tab bar out of its height and the probe under it
1296        // measures the rest, so the drawn slots account for the whole dock
1297        // once one tab bar per drawn slot is added back.
1298        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        // Every subset except "all three hidden", which gives the whole node
1307        // up to *its* parent and so has no container of its own to fill.
1308        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                    // A sentinel, so a slot that stopped drawing is not read
1313                    // as one that kept the height it had.
1314                    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    /// The old dock installed `ToggleZoom` and `ClosePanel` on the tab panel
1345    /// itself; base installs neither, so the skin's `frame` is the only place
1346    /// the keybindings reach.
1347    #[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    /// Withholding the control withholds the whole affordance, keybinding
1384    /// included. The two halves of the zoom question are asked in one place —
1385    /// the skin's `frame` — so a doc claiming the action gets through anyway
1386    /// would send a panel author looking for a second switch that does not
1387    /// exist.
1388    #[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    /// The tab group's own frame has to be a flex column.
1420    ///
1421    /// gpui's default display is Block, and block layout ignores a child's
1422    /// `flex_grow`. With a plain `div()` frame the content region's `flex_1`
1423    /// does nothing, `#tab-content` sizes to its content, and its only child
1424    /// is the panel view positioned absolutely by `cached` — which
1425    /// contributes no content height. The whole chain resolves to zero and
1426    /// the dock draws a tab bar with nothing under it.
1427    ///
1428    /// This asserts a dimension, which the project's testing guidance
1429    /// discourages, because a zero-height content region is not a cosmetic
1430    /// difference: it is the panel not rendering at all, and no behavioral
1431    /// test in this crate can see it. `set_active` still fires, the layout
1432    /// still round-trips, and the window still opens.
1433    #[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        // The tab bar is 30px and the padded content region adds none for a
1460        // single tab, so the panel should get nearly the whole window.
1461        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    /// A collapsed group is a strip of tabs with no content, and the actions
1469    /// act on content. The old `TabPanel::bind_actions` gated them the same
1470    /// way.
1471    ///
1472    /// The bottom dock, not a side one: a closed left or right dock draws
1473    /// nothing at all, so it would pass this whether or not the gate exists.
1474    #[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    /// A panel that carries its own chrome declines the one-panel title bar
1509    /// and gets the whole group.
1510    #[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    /// Panel with a chosen `closable` that records whether it was made active.
1583    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    /// Render a two-tab group through the real [`DockSkin`] and report whether
1632    /// the first tab drew a close button. The second tab is a non-closable
1633    /// filler (a lone panel draws no tab bar) that never draws one, so the
1634    /// probe is unambiguous.
1635    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    /// A closable panel's tab carries a close (X) button.
1669    #[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    /// A non-closable panel draws no close button (the gate is real).
1678    #[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    /// Clicking a non-active tab's close button removes that panel by id and
1724    /// does not select it (`stop_propagation`). The displayed tab is a
1725    /// non-closable filler, so the only close button is the one under test.
1726    #[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            // Filler at ix 0 is the displayed tab; the closable tab at ix 1 is
1745            // never active, so its close button is the only one drawn.
1746            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    /// A collapsed group offers no close button, though the same group does
1782    /// while open. Before/after collapse isolates `!collapsed`; nothing else
1783    /// changes.
1784    #[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        // Two closable panels so the group is draggable (not on its last
1795        // visible panel) and offers close buttons while open.
1796        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        // Collapse the bottom dock: its strip stays clickable, but the close
1817        // buttons on it must not.
1818        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}