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, 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/// The size the styled drag preview occupies, reported to base so a drop
44/// placeholder knows where to fly in from.
45const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = gpui::size(px(96.), px(30.));
46
47/// The preview that follows the cursor while a panel is dragged.
48///
49/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
50/// appearance half, reintroduced here.
51pub struct DragPanelPreview {
52    panel: Arc<dyn gpui_base::dock::PanelView>,
53}
54
55impl Render for DragPanelPreview {
56    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
57        div()
58            .id("drag-panel")
59            .cursor_grab()
60            .py_1()
61            .px_3()
62            .w_24()
63            .overflow_hidden()
64            .whitespace_nowrap()
65            .border_1()
66            .border_color(cx.theme().border)
67            .rounded(cx.theme().radius)
68            .text_color(cx.theme().tab_foreground)
69            .bg(cx.theme().tokens.tab_active)
70            .opacity(0.75)
71            .child(panel_title(&self.panel, window, cx))
72    }
73}
74
75/// A panel's title, or its registered name when it reached base without this
76/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
77pub(crate) fn panel_title(
78    panel: &Arc<dyn gpui_base::dock::PanelView>,
79    window: &mut Window,
80    cx: &mut App,
81) -> AnyElement {
82    let Some(handle) = PanelHandle::of(panel) else {
83        let name = panel.panel_name(cx);
84        warn_unwrapped_once(panel.panel_id(cx), name);
85        return SharedString::from(name).into_any_element();
86    };
87    handle.title(window, cx)
88}
89
90thread_local! {
91    /// Panels already warned about. `panel_title` sits on the render path, so
92    /// an unguarded warning would repeat at frame rate and bury the very
93    /// signal it exists to give.
94    ///
95    /// Keyed by panel rather than a bare `Once` so a second wrongly installed
96    /// panel is still named, and a runtime set rather than a `debug_assert!`
97    /// so a release build says it too — the consequence is a shipped app whose
98    /// tabs are titleless, which is exactly when someone needs to be told.
99    /// Thread-local because rendering happens on one thread, so no lock is
100    /// needed.
101    static WARNED_UNWRAPPED: RefCell<HashSet<PanelId>> = RefCell::new(HashSet::new());
102}
103
104/// Say once, per panel, that a panel reached the skin without its
105/// presentation handle. Silent otherwise, and visual-only: the panel docks,
106/// drags and persists, it just has no title. The shorter method is the wrong
107/// one — `DockLayout::panel` and `DockArea::add_panel` accept a
108/// `gpui_component::dock::Panel` and store the bare entity — so this says
109/// which panel and what to call instead.
110fn warn_unwrapped_once(panel: PanelId, name: &'static str) {
111    if !WARNED_UNWRAPPED.with(|warned| warned.borrow_mut().insert(panel)) {
112        return;
113    }
114    tracing::warn!(
115        panel = name,
116        "dock panel reached the skin without its presentation handle, so it \
117         draws its panel name instead of its title; install it with \
118         `gpui_component::dock::panel_handle(..)` and `DockLayout::panel_view` \
119         / `DockArea::add_panel_view` rather than `DockLayout::panel` / \
120         `DockArea::add_panel`"
121    );
122}
123
124/// Where the zoom affordance goes for the group's displayed panel, or `None`
125/// when there is none to offer.
126///
127/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
128/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
129/// whether zooming happens at all, and base refuses a zoom that fails it. The
130/// old dock had a single `zoomable() -> Option<PanelControl>` that could not
131/// disagree with itself; split across the seam it can, and a panel answering
132/// `zoomable() == false` with `zoom_control() == Some(Toolbar)` would
133/// otherwise draw a button that does nothing.
134fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
135    let panel = group.active_panel()?;
136    panel
137        .zoomable(cx)
138        .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
139        .flatten()
140}
141
142/// The payload for dragging the tab at `ix` out of its group, or `None` when
143/// this group must not be rearranged.
144///
145/// The guard is the skin's. [`TabGroupContext::drag_panel`] answers for any
146/// tab in range whether or not the group may be rearranged, so a tab bar that
147/// forgets to ask [`TabGroupContext::is_draggable`] makes a group that has
148/// nowhere to go — a dock's last group, a locked dock — draggable anyway. The
149/// old dock asked the same question, spelled `state.draggable`.
150fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
151    group
152        .is_draggable()
153        .then(|| group.drag_panel(ix, cx))
154        .flatten()
155}
156
157/// The left-most, top-most tab group in a container — where a left dock's
158/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
159fn left_top_group(node: &PaneNode) -> Option<NodeId> {
160    match node.kind() {
161        PaneRef::Tabs { .. } => Some(node.id()),
162        PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
163    }
164}
165
166/// The right-most, top-most tab group. A vertical split stacks its children,
167/// so its *first* child is the top one; a horizontal split's last child is the
168/// right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
169fn right_top_group(node: &PaneNode) -> Option<NodeId> {
170    match node.kind() {
171        PaneRef::Tabs { .. } => Some(node.id()),
172        PaneRef::Split { axis, children, .. } => match axis {
173            gpui::Axis::Vertical => children.first(),
174            gpui::Axis::Horizontal => children.last(),
175        }
176        .and_then(right_top_group),
177    }
178}
179
180/// One tab group's appearance.
181///
182/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once per
183/// container — so the tab bar's scroll position belongs to the group whose
184/// tabs it scrolls.
185pub(crate) struct TabGroupSkin {
186    shared: Rc<SkinShared>,
187    scroll_handle: ScrollHandle,
188    /// The displayed tab the last frame drew, so a change scrolls the new tab
189    /// into view. The old dock recorded this at the moment of selection; the
190    /// group now owns selection, so the skin notices instead of being told.
191    last_active_ix: Cell<Option<usize>>,
192}
193
194impl TabGroupSkin {
195    pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
196        Self {
197            shared,
198            scroll_handle: ScrollHandle::default(),
199            last_active_ix: Cell::new(None),
200        }
201    }
202
203    /// Whether a dock's collapse affordance belongs in *this* group's tab bar,
204    /// and which way it points. `None` means this group draws none.
205    fn dock_toggle_button(
206        &self,
207        placement: DockPlacement,
208        group: &TabGroupContext,
209        cx: &mut App,
210    ) -> Option<Button> {
211        if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
212            return None;
213        }
214
215        let area = self.shared.area().upgrade()?;
216        let area = area.read(cx);
217        // A dock that does not exist is not collapsible, so this covers the
218        // old `left_dock.is_some()` test too.
219        if !area.is_dock_collapsible(placement) {
220            return None;
221        }
222
223        let designated = match placement {
224            DockPlacement::Left => area
225                .layout(DockPlacement::Center)
226                .and_then(|tree| left_top_group(tree.root())),
227            DockPlacement::Right => area
228                .layout(DockPlacement::Center)
229                .and_then(|tree| right_top_group(tree.root())),
230            DockPlacement::Bottom => area
231                .layout(DockPlacement::Bottom)
232                .and_then(|tree| left_top_group(tree.root())),
233            DockPlacement::Center => None,
234        };
235        if designated != Some(group.node()) {
236            return None;
237        }
238
239        let is_open = area.is_dock_open(placement);
240        let icon = match (placement, is_open) {
241            (DockPlacement::Left, true) => IconName::PanelLeft,
242            (DockPlacement::Left, false) => IconName::PanelLeftOpen,
243            (DockPlacement::Right, true) => IconName::PanelRight,
244            (DockPlacement::Right, false) => IconName::PanelRightOpen,
245            (DockPlacement::Bottom, true) => IconName::PanelBottom,
246            (DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
247            (DockPlacement::Center, _) => return None,
248        };
249
250        let area = self.shared.area().clone();
251        Some(
252            Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
253                .icon(icon)
254                .xsmall()
255                .ghost()
256                .tab_stop(false)
257                .tooltip(match is_open {
258                    true => t!("Dock.Collapse"),
259                    false => t!("Dock.Expand"),
260                })
261                .on_click(move |_, window, cx| {
262                    _ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
263                }),
264        )
265    }
266
267    /// The trailing controls: the panel's own buttons, the zoom affordance,
268    /// and the ellipsis menu.
269    fn render_toolbar(
270        &self,
271        group: &TabGroupContext,
272        window: &mut Window,
273        cx: &mut App,
274    ) -> impl IntoElement {
275        if group.is_collapsed() {
276            return div();
277        }
278
279        let zoomed = group.is_zoomed();
280        let handle = group.active_panel().and_then(PanelHandle::of);
281        let control = zoom_control(group, cx);
282        let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
283        let menu_zoom = control.is_some_and(|control| control.menu_visible());
284        let closable = group.is_closable();
285        let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
286        let panel = handle.map(|handle| handle.panel());
287
288        h_flex()
289            .gap_1()
290            .occlude()
291            .when_some(buttons, |this, buttons| {
292                this.children(
293                    buttons
294                        .into_iter()
295                        .map(|button| button.xsmall().ghost().tab_stop(false)),
296                )
297            })
298            .when_some(
299                match (zoomed, toolbar_zoom) {
300                    (true, _) => Some(("zoom-out", IconName::Minimize, t!("Dock.Zoom Out"))),
301                    (false, true) => Some(("zoom-in", IconName::Maximize, t!("Dock.Zoom In"))),
302                    (false, false) => None,
303                },
304                |this, (id, icon, tooltip)| {
305                    this.child(
306                        Button::new(id)
307                            .icon(icon)
308                            .xsmall()
309                            .ghost()
310                            .tab_stop(false)
311                            .tooltip_with_action(tooltip, &ToggleZoom, None)
312                            .selected(zoomed)
313                            // Whether this button was drawn is the whole of
314                            // the `zoom_control` decision, and there is no
315                            // other way to ask a drawn tree about it. A no-op
316                            // outside test builds; see `debug_selector`.
317                            .debug_selector(|| ZOOM_CONTROL_SELECTOR.to_string())
318                            .on_click({
319                                let group = group.clone();
320                                move |_, window, cx| group.toggle_zoom(window, cx)
321                            }),
322                    )
323                },
324            )
325            .child(
326                Button::new("menu")
327                    .icon(IconName::Ellipsis)
328                    .xsmall()
329                    .ghost()
330                    .tab_stop(false)
331                    .dropdown_menu(move |menu, window, cx| {
332                        menu.when_some(panel.clone(), |menu, panel| {
333                            panel.dropdown_menu(menu, window, cx)
334                        })
335                        .separator()
336                        .menu_with_disabled(
337                            match zoomed {
338                                true => t!("Dock.Zoom Out"),
339                                false => t!("Dock.Zoom In"),
340                            },
341                            Box::new(ToggleZoom),
342                            !menu_zoom,
343                        )
344                        .when(closable, |menu| {
345                            menu.separator()
346                                .menu(t!("Dock.Close"), Box::new(ClosePanel))
347                        })
348                    })
349                    .anchor(Anchor::TopRight),
350            )
351    }
352
353    /// The one-panel title bar: no tabs, just the title and the controls.
354    fn render_title(
355        &self,
356        group: &TabGroupContext,
357        ix: usize,
358        window: &mut Window,
359        cx: &mut App,
360    ) -> AnyElement {
361        let panel = &group.panels()[ix];
362        let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
363        let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
364        let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
365        let has_leading = left_button.is_some() || bottom_button.is_some();
366        let handle = PanelHandle::of(panel);
367        let title_style = handle.and_then(|handle| handle.title_style(cx));
368        let drag = tab_drag(group, ix, cx);
369
370        h_flex()
371            .justify_between()
372            .h(px(30.))
373            .py_2()
374            .pl_3()
375            .pr_2()
376            .when(left_button.is_some(), |this| this.pl_2())
377            .when(right_button.is_some(), |this| this.pr_2())
378            .when_some(title_style, |this, style| {
379                this.bg(style.background).text_color(style.foreground)
380            })
381            .when(has_leading, |this| {
382                this.child(
383                    h_flex()
384                        .flex_shrink_0()
385                        .mr_1()
386                        .gap_1()
387                        .children(left_button)
388                        .children(bottom_button),
389                )
390            })
391            .child(
392                div()
393                    .id("tab")
394                    .flex_1()
395                    .min_w_16()
396                    .overflow_hidden()
397                    .text_ellipsis()
398                    .whitespace_nowrap()
399                    .child(panel_title(panel, window, cx))
400                    .when_some(drag, |this, drag| {
401                        this.on_drag(drag, {
402                            let panel = panel.clone();
403                            move |drag, offset, _, cx| {
404                                cx.stop_propagation();
405                                drag.set_drag_offset(offset);
406                                drag.set_preview_size(DRAG_PREVIEW_SIZE);
407                                cx.new(|_| DragPanelPreview {
408                                    panel: panel.clone(),
409                                })
410                            }
411                        })
412                    }),
413            )
414            .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
415            .child(
416                h_flex()
417                    .flex_shrink_0()
418                    .ml_1()
419                    .gap_1()
420                    .child(self.render_toolbar(group, window, cx))
421                    .children(right_button),
422            )
423            .into_any_element()
424    }
425
426    /// The full tab bar.
427    fn render_tabs(
428        &self,
429        group: &TabGroupContext,
430        window: &mut Window,
431        cx: &mut App,
432    ) -> AnyElement {
433        let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
434        let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
435        let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
436        let has_leading = left_button.is_some() || bottom_button.is_some();
437        let is_bottom_dock = bottom_button.is_some();
438        let collapsed = group.is_collapsed();
439
440        let droppable = group.is_droppable();
441        let tabs_count = group.panels().len();
442        let active_ix = group.active_ix();
443        let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
444        let visible: Vec<usize> = group
445            .panels()
446            .iter()
447            .enumerate()
448            .filter(|(_, panel)| panel.visible(cx))
449            .map(|(ix, _)| ix)
450            .collect();
451        let displayed_ix = displayed.and_then(|displayed| {
452            group
453                .panels()
454                .iter()
455                .position(|panel| panel.panel_id(cx) == displayed)
456        });
457
458        // Bring a newly displayed tab into view. The group owns selection now,
459        // so the skin notices the change rather than being told about it.
460        if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix) {
461            if let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix) {
462                self.scroll_handle.scroll_to_item(visible_ix);
463            }
464        }
465
466        TabBar::new("tab-bar")
467            .track_scroll(&self.scroll_handle)
468            .when(has_leading, |this| {
469                this.prefix(
470                    h_flex()
471                        .items_center()
472                        .top_0()
473                        // Right -1 for avoid border overlap with the first tab
474                        .right(-px(1.))
475                        .border_r_1()
476                        .border_b_1()
477                        .h_full()
478                        .border_color(cx.theme().border)
479                        .bg(cx.theme().tokens.tab_bar)
480                        .px_2()
481                        .children(left_button)
482                        .children(bottom_button),
483                )
484            })
485            .children(
486                visible
487                    .into_iter()
488                    .map(|ix| {
489                        let panel = &group.panels()[ix];
490                        let handle = PanelHandle::of(panel);
491                        let drag = tab_drag(group, ix, cx);
492
493                        Tab::new()
494                            .ix(ix)
495                            .tab_bar_prefix(has_leading)
496                            .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
497                                Some(tab_name) => this.child(tab_name),
498                                None => this.child(panel_title(panel, window, cx)),
499                            })
500                            // A collapsed group shows no tab as active: the
501                            // strip is a way back in, not a selection. The
502                            // comparison is against the panel on screen, not
503                            // the stored index: a hidden displayed tab falls
504                            // back to the first visible one.
505                            .selected(!collapsed && Some(ix) == displayed_ix)
506                            .on_click({
507                                let group = group.clone();
508                                let area = self.shared.area().clone();
509                                move |_, window, cx| {
510                                    group.select_tab(ix, window, cx);
511
512                                    // Clicking the strip of a collapsed bottom
513                                    // dock is how it is opened again.
514                                    if is_bottom_dock && collapsed {
515                                        _ = area.update(cx, |area, cx| {
516                                            area.toggle_dock(DockPlacement::Bottom, window, cx)
517                                        });
518                                    }
519                                }
520                            })
521                            // A collapsed group is a strip of tabs with no
522                            // content, so there is nothing to rearrange in it.
523                            .when(!collapsed, |this| {
524                                this.when_some(drag, |this, drag| {
525                                    this.on_drag(drag, {
526                                        let panel = panel.clone();
527                                        move |drag, offset, _, cx| {
528                                            cx.stop_propagation();
529                                            drag.set_drag_offset(offset);
530                                            drag.set_preview_size(DRAG_PREVIEW_SIZE);
531                                            cx.new(|_| DragPanelPreview {
532                                                panel: panel.clone(),
533                                            })
534                                        }
535                                    })
536                                })
537                                .when(droppable, |this| {
538                                    this.drag_over::<DragPanel>(|this, _, _, cx| {
539                                        this.rounded_l_none()
540                                            .border_l_2()
541                                            .border_r_0()
542                                            .border_color(cx.theme().drag_border)
543                                    })
544                                    .on_drop({
545                                        let group = group.clone();
546                                        move |drag: &DragPanel, window, cx| {
547                                            group.drop_panel(
548                                                drag.clone(),
549                                                Some(ix),
550                                                true,
551                                                window,
552                                                cx,
553                                            );
554                                        }
555                                    })
556                                    .drag_over::<AnyDrag>(|this, _, _, cx| {
557                                        this.rounded_l_none()
558                                            .border_l_2()
559                                            .border_r_0()
560                                            .border_color(cx.theme().drag_border)
561                                    })
562                                    .on_drop({
563                                        let group = group.clone();
564                                        move |item: &AnyDrag, window, cx| {
565                                            group.drop_item(item.clone(), None, window, cx);
566                                        }
567                                    })
568                                })
569                            })
570                    })
571                    .collect::<Vec<_>>(),
572            )
573            .last_empty_space(
574                // Empty space so a panel can be moved past the last tab.
575                div()
576                    .id("tab-bar-empty-space")
577                    .h_full()
578                    .flex_grow_1()
579                    .min_w_16()
580                    .when(droppable, |this| {
581                        this.drag_over::<DragPanel>(|this, _, _, cx| {
582                            this.bg(cx.theme().tokens.drop_target)
583                        })
584                        .on_drop({
585                            let group = group.clone();
586                            let node = group.node();
587                            move |drag: &DragPanel, window, cx| {
588                                // A panel dropped past its own last tab lands
589                                // in the final slot; one from elsewhere is
590                                // appended in the background.
591                                let ix = (drag.source() == node).then(|| tabs_count - 1);
592                                group.drop_panel(drag.clone(), ix, false, window, cx);
593                            }
594                        })
595                        .drag_over::<AnyDrag>(|this, _, _, cx| {
596                            this.bg(cx.theme().tokens.drop_target)
597                        })
598                        .on_drop({
599                            let group = group.clone();
600                            move |item: &AnyDrag, window, cx| {
601                                group.drop_item(item.clone(), None, window, cx);
602                            }
603                        })
604                    }),
605            )
606            .when(!collapsed, |this| {
607                this.suffix(
608                    h_flex()
609                        .items_center()
610                        .top_0()
611                        .right_0()
612                        .border_l_1()
613                        .border_b_1()
614                        .h_full()
615                        .border_color(cx.theme().border)
616                        .bg(cx.theme().tokens.tab_bar)
617                        .px_2()
618                        .gap_1()
619                        .children(
620                            group
621                                .active_panel()
622                                .and_then(PanelHandle::of)
623                                .and_then(|handle| handle.title_suffix(window, cx)),
624                        )
625                        .child(self.render_toolbar(group, window, cx))
626                        .children(right_button),
627                )
628            })
629            .into_any_element()
630    }
631}
632
633impl TabGroupRenderer for TabGroupSkin {
634    fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
635        let control = zoom_control(group, cx);
636
637        // The column, the fill and the clip are base's now, applied around
638        // this. What is left is the background and the two actions.
639        div()
640            .id("tab-panel")
641            .bg(cx.theme().tokens.background)
642            // A collapsed group is a strip of tabs with no content, and the
643            // actions act on content. The old dock gated them the same way.
644            .when(!group.is_collapsed(), |this| {
645                this.on_action({
646                    let group = group.clone();
647                    move |_: &ToggleZoom, window, cx| {
648                        // The affordance decides the control, so a panel that
649                        // offers none is not zoomed *in* by the keybinding
650                        // either. Zooming out is never refused: a panel that
651                        // stopped offering the control while zoomed would
652                        // otherwise strand the user with no way back.
653                        if !group.is_zoomed() && control.is_none() {
654                            return;
655                        }
656                        group.toggle_zoom(window, cx);
657                    }
658                })
659                .on_action({
660                    let group = group.clone();
661                    move |_: &ClosePanel, window, cx| {
662                        let Some(panel) = group.active_panel() else {
663                            return;
664                        };
665                        let panel = panel.panel_id(cx);
666                        group.close(panel, window, cx);
667                    }
668                })
669            })
670    }
671
672    fn content_frame(
673        &self,
674        group: &TabGroupContext,
675        _: &mut Window,
676        cx: &mut App,
677    ) -> Stateful<Div> {
678        let padded = group.panels().len() > 1
679            && group
680                .active_panel()
681                .and_then(PanelHandle::of)
682                .is_none_or(|handle| handle.inner_padding(cx));
683
684        // The fill and the collapsed-group exception are base's; the padding
685        // is this skin's, and is the only reason this hook is implemented.
686        div().id("active-panel").when(padded, |this| this.pt_2())
687    }
688
689    fn render_tab_bar(
690        &self,
691        group: &TabGroupContext,
692        window: &mut Window,
693        cx: &mut App,
694    ) -> AnyElement {
695        let visible: Vec<usize> = group
696            .panels()
697            .iter()
698            .enumerate()
699            .filter(|(_, panel)| panel.visible(cx))
700            .map(|(ix, _)| ix)
701            .collect();
702
703        match visible.as_slice() {
704            [] => Empty.into_any_element(),
705            [ix] if self.shared.panel_style() == PanelStyle::Auto => {
706                // A panel that draws its own chrome declines the title bar.
707                let panel = &group.panels()[*ix];
708                if PanelHandle::of(panel).is_some_and(|handle| !handle.title_bar(cx)) {
709                    return Empty.into_any_element();
710                }
711                self.render_title(group, *ix, window, cx)
712            }
713            _ => self.render_tabs(group, window, cx),
714        }
715    }
716
717    fn render_active_panel(
718        &self,
719        panel: AnyView,
720        group: &TabGroupContext,
721        _: &mut Window,
722        _: &mut App,
723    ) -> AnyElement {
724        if group.is_collapsed() {
725            return Empty.into_any_element();
726        }
727
728        div()
729            .id("tab-content")
730            .overflow_y_scroll()
731            .overflow_x_hidden()
732            .flex_1()
733            .child(panel.cached(StyleRefinement::default().absolute().size_full()))
734            .into_any_element()
735    }
736
737    fn render_drop_indicator(
738        &self,
739        indicator: DropIndicator,
740        window: &mut Window,
741        cx: &mut App,
742    ) -> Option<AnyElement> {
743        let to = indicator.to();
744        // The placeholder chases the drop it would land in. Its rect was
745        // previously replayed from the drag source on every epoch, so crossing
746        // several drop zones in one drag restarted the walk at each one; the
747        // springs carry it through instead, and the element no longer needs an
748        // outer frame to hold the destination while an inner one walks to it.
749        let id = "drop-placeholder";
750        let placeholder_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
751        let left = spring((id, "left"), to.origin().x, placeholder_spring, window, cx);
752        let top = spring((id, "top"), to.origin().y, placeholder_spring, window, cx);
753        let width = spring(
754            (id, "width"),
755            to.size().width,
756            placeholder_spring,
757            window,
758            cx,
759        );
760        let height = spring(
761            (id, "height"),
762            to.size().height,
763            placeholder_spring,
764            window,
765            cx,
766        );
767
768        Some(
769            div()
770                .absolute()
771                .bg(cx.theme().tokens.drop_target)
772                .left(left)
773                .top(top)
774                .w(width)
775                .h(height)
776                .into_any_element(),
777        )
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use std::{
784        cell::{Cell, RefCell},
785        sync::Arc,
786    };
787
788    use gpui::{
789        Entity, EventEmitter, FocusHandle, Focusable, Pixels, TestAppContext, VisualTestContext,
790    };
791    use gpui_base::dock::{DockArea, DockAreaRenderer, DockLayout, DockPlacement, PanelEvent};
792
793    use super::*;
794    use crate::{
795        ElementExt as _,
796        dock::{
797            DockSkin, Panel, panel_handle,
798            test_support::{HideableProbe, MeasuredProbe},
799        },
800    };
801
802    struct Probe {
803        focus_handle: FocusHandle,
804    }
805
806    impl Probe {
807        fn new(cx: &mut App) -> Entity<Self> {
808            cx.new(|cx| Self {
809                focus_handle: cx.focus_handle(),
810            })
811        }
812    }
813
814    impl gpui_base::dock::Panel for Probe {
815        fn panel_name(&self) -> &'static str {
816            "Probe"
817        }
818    }
819
820    impl Panel for Probe {}
821    impl EventEmitter<PanelEvent> for Probe {}
822
823    impl Focusable for Probe {
824        fn focus_handle(&self, _: &App) -> FocusHandle {
825            self.focus_handle.clone()
826        }
827    }
828
829    impl Render for Probe {
830        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
831            Empty
832        }
833    }
834
835    /// Draws nothing, but runs the real gates the skin's tab bar runs and
836    /// records what they decided for every group it was asked to draw.
837    #[derive(Default)]
838    struct Recorded {
839        /// One entry per tab: whether its tab would start a drag.
840        draggable: Vec<bool>,
841    }
842
843    struct Recorder {
844        log: Rc<RefCell<Recorded>>,
845    }
846
847    impl TabGroupRenderer for Recorder {
848        fn render_tab_bar(
849            &self,
850            group: &TabGroupContext,
851            _: &mut Window,
852            cx: &mut App,
853        ) -> AnyElement {
854            let mut log = self.log.borrow_mut();
855            for ix in 0..group.panels().len() {
856                log.draggable.push(tab_drag(group, ix, cx).is_some());
857            }
858            Empty.into_any_element()
859        }
860    }
861
862    impl DockAreaRenderer for Recorder {
863        fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
864            div().id("recorder").size_full()
865        }
866
867        fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
868            Rc::new(Recorder {
869                log: self.log.clone(),
870            })
871        }
872    }
873
874    fn recording_area(
875        cx: &mut TestAppContext,
876    ) -> (
877        Entity<DockArea>,
878        Rc<RefCell<Recorded>>,
879        &mut VisualTestContext,
880    ) {
881        cx.update(|cx| {
882            crate::init(cx);
883        });
884        let log = Rc::new(RefCell::new(Recorded::default()));
885        let renderer = Rc::new(Recorder { log: log.clone() });
886        let (area, cx) = cx.add_window_view(|window, cx| {
887            DockArea::new("skin", None, window, cx).with_renderer(renderer)
888        });
889        (area, log, cx)
890    }
891
892    /// The gate carried forward from the old `TabPanel::render`, which wrapped
893    /// `on_drag` in `.when(state.draggable, ..)`. Base does not enforce it:
894    /// `TabGroupContext::drag_panel` answers for any tab in range.
895    #[gpui::test]
896    fn the_last_group_in_a_dock_offers_no_drag(cx: &mut TestAppContext) {
897        let (area, log, cx) = recording_area(cx);
898
899        cx.update(|window, cx| {
900            let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
901            area.update(cx, |area, cx| area.set_center(layout, window, cx));
902        });
903        cx.run_until_parked();
904        log.borrow_mut().draggable.clear();
905        cx.update(|window, cx| window.draw(cx).clear(cx));
906
907        assert_eq!(
908            log.borrow().draggable,
909            vec![false],
910            "the only visible panel in the dock has nowhere to go, so its tab \
911             must not start a drag"
912        );
913    }
914
915    #[gpui::test]
916    fn a_group_beside_another_offers_a_drag(cx: &mut TestAppContext) {
917        let (area, log, cx) = recording_area(cx);
918
919        cx.update(|window, cx| {
920            let layout = DockLayout::h_split()
921                .child(
922                    DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
923                    None,
924                )
925                .child(
926                    DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
927                    None,
928                );
929            area.update(cx, |area, cx| area.set_center(layout, window, cx));
930        });
931        cx.run_until_parked();
932        log.borrow_mut().draggable.clear();
933        cx.update(|window, cx| window.draw(cx).clear(cx));
934
935        assert_eq!(
936            log.borrow().draggable,
937            vec![true, true],
938            "each group has somewhere to go, so both tabs start a drag"
939        );
940    }
941
942    /// A panel that allows zooming and asks for the control in the toolbar.
943    /// `Probe`'s default is `PanelControl::Menu`, which draws no button.
944    struct ToolbarZoomProbe {
945        focus_handle: FocusHandle,
946    }
947
948    impl ToolbarZoomProbe {
949        fn new(cx: &mut App) -> Entity<Self> {
950            cx.new(|cx| Self {
951                focus_handle: cx.focus_handle(),
952            })
953        }
954    }
955
956    impl gpui_base::dock::Panel for ToolbarZoomProbe {
957        fn panel_name(&self) -> &'static str {
958            "ToolbarZoomProbe"
959        }
960    }
961
962    impl Panel for ToolbarZoomProbe {
963        fn zoom_control(&self, _: &App) -> Option<PanelControl> {
964            Some(PanelControl::Toolbar)
965        }
966    }
967
968    impl EventEmitter<PanelEvent> for ToolbarZoomProbe {}
969
970    impl Focusable for ToolbarZoomProbe {
971        fn focus_handle(&self, _: &App) -> FocusHandle {
972            self.focus_handle.clone()
973        }
974    }
975
976    impl Render for ToolbarZoomProbe {
977        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
978            Empty
979        }
980    }
981
982    /// A panel that offers no zoom control but leaves base's `zoomable`
983    /// default alone. Withholding the control is meant to be enough.
984    struct NoControlProbe {
985        focus_handle: FocusHandle,
986    }
987
988    impl NoControlProbe {
989        fn new(cx: &mut App) -> Entity<Self> {
990            cx.new(|cx| Self {
991                focus_handle: cx.focus_handle(),
992            })
993        }
994    }
995
996    impl gpui_base::dock::Panel for NoControlProbe {
997        fn panel_name(&self) -> &'static str {
998            "NoControlProbe"
999        }
1000    }
1001
1002    impl Panel for NoControlProbe {
1003        fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1004            None
1005        }
1006    }
1007
1008    impl EventEmitter<PanelEvent> for NoControlProbe {}
1009
1010    impl Focusable for NoControlProbe {
1011        fn focus_handle(&self, _: &App) -> FocusHandle {
1012            self.focus_handle.clone()
1013        }
1014    }
1015
1016    impl Render for NoControlProbe {
1017        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1018            Empty
1019        }
1020    }
1021
1022    /// A panel that says "never zoom" in base's half but still names a place
1023    /// for the control in this crate's half.
1024    struct UnzoomableProbe {
1025        focus_handle: FocusHandle,
1026    }
1027
1028    impl gpui_base::dock::Panel for UnzoomableProbe {
1029        fn panel_name(&self) -> &'static str {
1030            "UnzoomableProbe"
1031        }
1032
1033        fn zoomable(&self, _: &App) -> bool {
1034            false
1035        }
1036    }
1037
1038    impl Panel for UnzoomableProbe {
1039        fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1040            Some(PanelControl::Toolbar)
1041        }
1042    }
1043
1044    impl EventEmitter<PanelEvent> for UnzoomableProbe {}
1045
1046    impl Focusable for UnzoomableProbe {
1047        fn focus_handle(&self, _: &App) -> FocusHandle {
1048            self.focus_handle.clone()
1049        }
1050    }
1051
1052    impl Render for UnzoomableProbe {
1053        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1054            Empty
1055        }
1056    }
1057
1058    /// Draw one panel through the real [`DockSkin`] and report whether its tab
1059    /// bar offered a zoom control.
1060    ///
1061    /// The real skin, not a recorder: the bug this guards is `render_toolbar`
1062    /// asking only half the question, so a test that calls `zoom_control`
1063    /// itself would pass with the bug in place.
1064    fn drew_zoom_control(
1065        cx: &mut TestAppContext,
1066        panel: impl FnOnce(&mut App) -> Arc<dyn gpui_base::dock::PanelView>,
1067    ) -> bool {
1068        cx.update(|cx| {
1069            crate::init(cx);
1070        });
1071        let (area, cx) = cx.add_window_view(|window, cx| {
1072            let skin = DockSkin::new(cx);
1073            DockArea::new("skin", None, window, cx).with_renderer(skin)
1074        });
1075
1076        cx.update(|window, cx| {
1077            let layout = DockLayout::tabs().panel_view(panel(cx), cx);
1078            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1079        });
1080        cx.run_until_parked();
1081        cx.update(|window, cx| window.draw(cx).clear(cx));
1082        cx.debug_bounds(ZOOM_CONTROL_SELECTOR).is_some()
1083    }
1084
1085    /// The two halves of the old `zoomable()` can now disagree, and base is
1086    /// the one that decides. A control drawn against base's refusal is dead:
1087    /// pressing it does nothing.
1088    #[gpui::test]
1089    fn a_panel_base_will_not_zoom_gets_no_zoom_control(cx: &mut TestAppContext) {
1090        let drew = drew_zoom_control(cx, |cx| {
1091            panel_handle(cx.new(|cx| UnzoomableProbe {
1092                focus_handle: cx.focus_handle(),
1093            }))
1094        });
1095
1096        assert!(
1097            !drew,
1098            "the panel names a place for the control, but base refuses the zoom"
1099        );
1100    }
1101
1102    /// The other half: a panel that allows zoom and asks for a toolbar control
1103    /// gets one drawn. Without this the test above would also pass a skin that
1104    /// never draws a zoom control at all.
1105    #[gpui::test]
1106    fn a_zoomable_panel_gets_its_zoom_control(cx: &mut TestAppContext) {
1107        let drew = drew_zoom_control(cx, |cx| panel_handle(ToolbarZoomProbe::new(cx)));
1108
1109        assert!(
1110            drew,
1111            "a zoomable panel asking for a toolbar control gets one"
1112        );
1113    }
1114
1115    /// The centre and the bottom dock share the centre column, and both get
1116    /// height.
1117    ///
1118    /// `DockSkin::center_frame` is a flex column holding the centre's root
1119    /// split and the bottom dock, and the centre's split frame sits between
1120    /// them. This pins that the frame carries a size at all: strip
1121    /// `split_frame` of both `size_full` and `flex_1` and every panel here
1122    /// measures zero. It does not pin *which* of the two does the work —
1123    /// either alone passes.
1124    #[gpui::test]
1125    fn the_centre_and_the_bottom_dock_share_the_column(cx: &mut TestAppContext) {
1126        cx.update(|cx| {
1127            crate::init(cx);
1128        });
1129        let centre = Rc::new(Cell::new(px(0.)));
1130        let bottom = Rc::new(Cell::new(px(0.)));
1131        let (area, cx) = cx.add_window_view(|window, cx| {
1132            let skin = DockSkin::new(cx);
1133            DockArea::new("skin", None, window, cx).with_renderer(skin)
1134        });
1135
1136        let (centre_probe, bottom_probe) = (centre.clone(), bottom.clone());
1137        cx.update(|window, cx| {
1138            let centre_panel = MeasuredProbe::new(centre_probe, cx);
1139            let bottom_panel = MeasuredProbe::new(bottom_probe, cx);
1140            area.update(cx, |area, cx| {
1141                // A split inside a split, so the *nested* `split_frame` — the
1142                // one that sits inside a `resizable_panel` — is exercised too,
1143                // not only the centre's root.
1144                area.set_center(
1145                    DockLayout::v_split().child(
1146                        DockLayout::h_split().child(
1147                            DockLayout::tabs().panel_view(panel_handle(centre_panel), cx),
1148                            None,
1149                        ),
1150                        None,
1151                    ),
1152                    window,
1153                    cx,
1154                );
1155                area.set_dock(
1156                    DockPlacement::Bottom,
1157                    DockLayout::tabs().panel_view(panel_handle(bottom_panel), cx),
1158                    window,
1159                    cx,
1160                );
1161                area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
1162            });
1163        });
1164        cx.run_until_parked();
1165        cx.update(|window, cx| window.draw(cx).clear(cx));
1166
1167        let window_height = cx.update(|window, _| window.viewport_size().height);
1168        assert!(
1169            centre.get() > px(0.),
1170            "the centre panel must receive height; it got {:?}",
1171            centre.get()
1172        );
1173        assert!(
1174            bottom.get() > px(0.),
1175            "the bottom dock's panel must receive height; it got {:?}",
1176            bottom.get()
1177        );
1178        assert!(
1179            centre.get() < window_height - px(150.),
1180            "the centre must give the 200px bottom dock its share; the centre \
1181             got {:?} of {window_height:?}",
1182            centre.get()
1183        );
1184    }
1185
1186    /// Whichever slots of a split are hidden, the drawn ones fill it.
1187    ///
1188    /// `render_node` pins every slot but one to a fixed size and lets the
1189    /// remaining one absorb whatever the container has spare. Picking that
1190    /// slot by tree position alone picks a hidden one whenever the trailing
1191    /// container's panels are all hidden — nothing draws there, nothing
1192    /// grows, and the split stops short of its frame, showing a band of the
1193    /// frame's own background under the last visible panel. Hiding a slot is
1194    /// an everyday event: a panel that is only meaningful for some symbols
1195    /// answers `visible` with `false` for the rest.
1196    ///
1197    /// Every subset is covered because the defect is positional: only the
1198    /// cases that hide the trailing slot fail, and a test that hid one fixed
1199    /// slot would pass against a fix that only special-cased that slot.
1200    #[gpui::test]
1201    fn a_split_fills_its_container_whichever_slots_are_hidden(cx: &mut TestAppContext) {
1202        cx.update(|cx| {
1203            crate::init(cx);
1204        });
1205        let heights: Vec<Rc<Cell<Pixels>>> = (0..3).map(|_| Rc::new(Cell::new(px(0.)))).collect();
1206        let (area, cx) = cx.add_window_view(|window, cx| {
1207            let skin = DockSkin::new(cx);
1208            DockArea::new("skin", None, window, cx).with_renderer(skin)
1209        });
1210
1211        let slots = heights.clone();
1212        let probes = cx.update(|window, cx| {
1213            let probes: Vec<_> = slots
1214                .iter()
1215                .map(|height| HideableProbe::new(height.clone(), cx))
1216                .collect();
1217            area.update(cx, |area, cx| {
1218                area.set_dock(
1219                    DockPlacement::Right,
1220                    probes.iter().zip([260., 320., 200.]).fold(
1221                        DockLayout::v_split(),
1222                        |split, (probe, size)| {
1223                            split.child(
1224                                DockLayout::tabs().panel_view(panel_handle(probe.clone()), cx),
1225                                Some(px(size)),
1226                            )
1227                        },
1228                    ),
1229                    window,
1230                    cx,
1231                );
1232                area.set_dock_size(DockPlacement::Right, px(380.), window, cx);
1233            });
1234            probes
1235        });
1236        cx.run_until_parked();
1237        let draw = |cx: &mut VisualTestContext| {
1238            cx.update(|window, _| window.refresh());
1239            cx.update(|window, cx| window.draw(cx).clear(cx));
1240            cx.update(|window, cx| window.draw(cx).clear(cx));
1241        };
1242        draw(cx);
1243
1244        let dock_height = cx.update(|window, _| window.viewport_size().height);
1245        // Each slot spends a tab bar out of its height and the probe under it
1246        // measures the rest, so the drawn slots account for the whole dock
1247        // once one tab bar per drawn slot is added back.
1248        let drawn: Pixels = heights.iter().map(|height| height.get()).sum();
1249        let bar = (dock_height - drawn) / 3.;
1250        assert!(
1251            bar > px(0.) && bar < px(60.),
1252            "the three slots fill the dock to begin with, one tab bar each; \
1253             that leaves {bar:?} per slot of {dock_height:?}"
1254        );
1255
1256        // Every subset except "all three hidden", which gives the whole node
1257        // up to *its* parent and so has no container of its own to fill.
1258        for hidden in 1..0b111u8 {
1259            let shown = (0..3).filter(|slot| hidden & (1 << slot) == 0);
1260            cx.update(|_, cx| {
1261                for (slot, probe) in probes.iter().enumerate() {
1262                    // A sentinel, so a slot that stopped drawing is not read
1263                    // as one that kept the height it had.
1264                    heights[slot].set(px(-1.));
1265                    probe.update(cx, |probe, cx| {
1266                        probe.set_visible(hidden & (1 << slot) == 0, cx)
1267                    });
1268                }
1269            });
1270            cx.run_until_parked();
1271            draw(cx);
1272
1273            let mut count = 0;
1274            let mut total = px(0.);
1275            for slot in shown {
1276                assert_ne!(
1277                    heights[slot].get(),
1278                    px(-1.),
1279                    "hiding {hidden:03b}: slot {slot} is shown and must draw"
1280                );
1281                count += 1;
1282                total += heights[slot].get();
1283            }
1284            let empty = dock_height - total - bar * count as f32;
1285            assert!(
1286                empty.abs() < px(1.),
1287                "hiding {hidden:03b}: the drawn slots must take the hidden \
1288                 ones' space between them; they left {empty:?} of \
1289                 {dock_height:?} empty"
1290            );
1291        }
1292    }
1293
1294    /// The old dock installed `ToggleZoom` and `ClosePanel` on the tab panel
1295    /// itself; base installs neither, so the skin's `frame` is the only place
1296    /// the keybindings reach.
1297    #[gpui::test]
1298    fn the_zoom_action_reaches_the_group_through_the_skin(cx: &mut TestAppContext) {
1299        cx.update(|cx| {
1300            crate::init(cx);
1301        });
1302        let (area, cx) = cx.add_window_view(|window, cx| {
1303            let skin = DockSkin::new(cx);
1304            DockArea::new("skin", None, window, cx).with_renderer(skin)
1305        });
1306
1307        let panel = cx.update(|window, cx| {
1308            let panel = Probe::new(cx);
1309            let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1310            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1311            panel
1312        });
1313        cx.run_until_parked();
1314        cx.update(|window, cx| {
1315            panel.read(cx).focus_handle(cx).focus(window, cx);
1316        });
1317        cx.run_until_parked();
1318
1319        assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1320        cx.dispatch_action(ToggleZoom);
1321        cx.run_until_parked();
1322        assert_eq!(
1323            cx.read(|cx| area.read(cx).is_zoomed()),
1324            true,
1325            "the skin's frame is what carries the ToggleZoom handler"
1326        );
1327
1328        cx.dispatch_action(ToggleZoom);
1329        cx.run_until_parked();
1330        assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1331    }
1332
1333    /// Withholding the control withholds the whole affordance, keybinding
1334    /// included. The two halves of the zoom question are asked in one place —
1335    /// the skin's `frame` — so a doc claiming the action gets through anyway
1336    /// would send a panel author looking for a second switch that does not
1337    /// exist.
1338    #[gpui::test]
1339    fn the_zoom_action_refuses_a_panel_that_offers_no_control(cx: &mut TestAppContext) {
1340        cx.update(|cx| {
1341            crate::init(cx);
1342        });
1343        let (area, cx) = cx.add_window_view(|window, cx| {
1344            let skin = DockSkin::new(cx);
1345            DockArea::new("skin", None, window, cx).with_renderer(skin)
1346        });
1347
1348        let panel = cx.update(|window, cx| {
1349            let panel = NoControlProbe::new(cx);
1350            let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1351            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1352            panel
1353        });
1354        cx.run_until_parked();
1355        cx.update(|window, cx| {
1356            panel.read(cx).focus_handle(cx).focus(window, cx);
1357        });
1358        cx.run_until_parked();
1359
1360        cx.dispatch_action(ToggleZoom);
1361        cx.run_until_parked();
1362        assert_eq!(
1363            cx.read(|cx| area.read(cx).is_zoomed()),
1364            false,
1365            "no control means no zoom, however the zoom was asked for"
1366        );
1367    }
1368
1369    /// The tab group's own frame has to be a flex column.
1370    ///
1371    /// gpui's default display is Block, and block layout ignores a child's
1372    /// `flex_grow`. With a plain `div()` frame the content region's `flex_1`
1373    /// does nothing, `#tab-content` sizes to its content, and its only child
1374    /// is the panel view positioned absolutely by `cached` — which
1375    /// contributes no content height. The whole chain resolves to zero and
1376    /// the dock draws a tab bar with nothing under it.
1377    ///
1378    /// This asserts a dimension, which the project's testing guidance
1379    /// discourages, because a zero-height content region is not a cosmetic
1380    /// difference: it is the panel not rendering at all, and no behavioral
1381    /// test in this crate can see it. `set_active` still fires, the layout
1382    /// still round-trips, and the window still opens.
1383    #[gpui::test]
1384    fn the_panel_content_region_gets_the_height_below_the_tab_bar(cx: &mut TestAppContext) {
1385        cx.update(|cx| {
1386            crate::init(cx);
1387        });
1388        let height = Rc::new(Cell::new(px(0.)));
1389        let (area, cx) = cx.add_window_view(|window, cx| {
1390            let skin = DockSkin::new(cx);
1391            DockArea::new("skin", None, window, cx).with_renderer(skin)
1392        });
1393
1394        let measured = height.clone();
1395        cx.update(|window, cx| {
1396            let panel = MeasuredProbe::new(measured, cx);
1397            let layout = DockLayout::tabs().panel_view(panel_handle(panel), cx);
1398            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1399        });
1400        cx.run_until_parked();
1401        cx.update(|window, cx| window.draw(cx).clear(cx));
1402
1403        let window_height = cx.update(|window, _| window.viewport_size().height);
1404        let content = height.get();
1405        assert!(
1406            content > px(0.),
1407            "the panel must receive height; it got {content:?} in a {window_height:?} window"
1408        );
1409        // The tab bar is 30px and the padded content region adds none for a
1410        // single tab, so the panel should get nearly the whole window.
1411        assert!(
1412            content > window_height - px(60.),
1413            "the panel should fill what the tab bar leaves; it got {content:?} \
1414             of {window_height:?}"
1415        );
1416    }
1417
1418    /// A collapsed group is a strip of tabs with no content, and the actions
1419    /// act on content. The old `TabPanel::bind_actions` gated them the same
1420    /// way.
1421    ///
1422    /// The bottom dock, not a side one: a closed left or right dock draws
1423    /// nothing at all, so it would pass this whether or not the gate exists.
1424    #[gpui::test]
1425    fn a_collapsed_dock_ignores_the_zoom_action(cx: &mut TestAppContext) {
1426        cx.update(|cx| {
1427            crate::init(cx);
1428        });
1429        let (area, cx) = cx.add_window_view(|window, cx| {
1430            let skin = DockSkin::new(cx);
1431            DockArea::new("skin", None, window, cx).with_renderer(skin)
1432        });
1433
1434        let panel = cx.update(|window, cx| {
1435            let panel = Probe::new(cx);
1436            let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1437            area.update(cx, |area, cx| {
1438                area.set_dock(DockPlacement::Bottom, layout, window, cx);
1439                area.toggle_dock(DockPlacement::Bottom, window, cx);
1440            });
1441            panel
1442        });
1443        cx.run_until_parked();
1444        cx.update(|window, cx| {
1445            panel.read(cx).focus_handle(cx).focus(window, cx);
1446        });
1447        cx.run_until_parked();
1448
1449        cx.dispatch_action(ToggleZoom);
1450        cx.run_until_parked();
1451        assert_eq!(
1452            cx.read(|cx| area.read(cx).is_zoomed()),
1453            false,
1454            "a collapsed group installs no action handler"
1455        );
1456    }
1457
1458    /// A panel that carries its own chrome declines the one-panel title bar
1459    /// and gets the whole group.
1460    #[gpui::test]
1461    fn a_panel_without_a_title_bar_gets_the_whole_group(cx: &mut TestAppContext) {
1462        struct Chromeless {
1463            focus_handle: FocusHandle,
1464            height: Rc<Cell<Pixels>>,
1465        }
1466
1467        impl gpui_base::dock::Panel for Chromeless {
1468            fn panel_name(&self) -> &'static str {
1469                "Chromeless"
1470            }
1471        }
1472
1473        impl Panel for Chromeless {
1474            fn title_bar(&self, _: &App) -> bool {
1475                false
1476            }
1477        }
1478
1479        impl EventEmitter<PanelEvent> for Chromeless {}
1480
1481        impl Focusable for Chromeless {
1482            fn focus_handle(&self, _: &App) -> FocusHandle {
1483                self.focus_handle.clone()
1484            }
1485        }
1486
1487        impl Render for Chromeless {
1488            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1489                let height = self.height.clone();
1490                div()
1491                    .size_full()
1492                    .on_prepaint(move |bounds, _, _| height.set(bounds.size.height))
1493            }
1494        }
1495
1496        cx.update(|cx| crate::init(cx));
1497
1498        let measure = |cx: &mut TestAppContext, chromeless: bool| -> Pixels {
1499            let height = Rc::new(Cell::new(px(0.)));
1500            let (area, cx) = cx.add_window_view(|window, cx| {
1501                let skin = DockSkin::new(cx);
1502                DockArea::new("skin", None, window, cx).with_renderer(skin)
1503            });
1504            let measured = height.clone();
1505            cx.update(|window, cx| {
1506                let panel: Arc<dyn gpui_base::dock::PanelView> = if chromeless {
1507                    panel_handle(cx.new(|cx| Chromeless {
1508                        focus_handle: cx.focus_handle(),
1509                        height: measured,
1510                    }))
1511                } else {
1512                    panel_handle(MeasuredProbe::new(measured, cx))
1513                };
1514                let layout = DockLayout::tabs().panel_view(panel, cx);
1515                area.update(cx, |area, cx| area.set_center(layout, window, cx));
1516            });
1517            cx.run_until_parked();
1518            cx.update(|window, cx| window.draw(cx).clear(cx));
1519            height.get()
1520        };
1521
1522        let with_title = measure(cx, false);
1523        let without = measure(cx, true);
1524        assert!(with_title > px(0.), "the probe must have been drawn");
1525        assert_eq!(
1526            without,
1527            with_title + px(30.),
1528            "the 30px the title bar took go to the panel instead"
1529        );
1530    }
1531}