Skip to main content

gpui_component/dock/
tab_panel.rs

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