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