Skip to main content

gpui_base/dock/
tab_group.rs

1//! A tab group's behavior, with no appearance of its own.
2
3use crate::TestSupportExt as _;
4use std::{rc::Rc, sync::Arc};
5
6use gpui::{
7    AnyElement, AnyView, App, Bounds, Context, Div, DragMoveEvent, Empty, EventEmitter,
8    FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
9    Render, Stateful, Styled as _, WeakEntity, Window, div, prelude::FluentBuilder as _, px,
10};
11
12use crate::Placement;
13
14use super::{
15    active::ActiveTracker,
16    drag::{
17        AnyDrag, DragPanel, DropIndicator, DropPlaceholderBounds, DropTarget, ITEM_DRAG_SESSION_ID,
18        split_placement_at,
19    },
20    layout::{InsertTarget, NodeId, PanelId},
21    panel::PanelView,
22};
23
24/// Behavior a tab group cannot carry out on its own.
25///
26/// Every variant here ends in an edit to the layout tree, and the tree belongs
27/// to the container that owns this group. Reporting the intent instead of
28/// reaching for the container keeps the whole detach-then-reinsert dance —
29/// and the reentrancy it used to provoke — out of the group entirely.
30#[non_exhaustive]
31pub enum TabGroupEvent {
32    /// A panel was dropped on this group. `target` says where in the tree it
33    /// lands; the container applies it as a single `PaneTree::move_panel`.
34    Drop {
35        panel: PanelId,
36        source: NodeId,
37        target: InsertTarget,
38    },
39    /// A host-owned drag landed on this group.
40    DragDrop { item: AnyDrag, target: DropTarget },
41    /// The user asked to close `panel`.
42    ClosePanel { panel: PanelId },
43    /// The displayed tab changed, so the tree's stored `active_ix` is stale.
44    ActiveChanged { ix: usize },
45    /// This group asked to fill the whole dock. The container installs the
46    /// *group* as its zoomed view, chrome and all — the tab bar is where the
47    /// control that zooms back out lives.
48    ZoomIn,
49    /// This group gave the dock back.
50    ZoomOut,
51}
52
53/// Everything the container knows about a group's place in the dock.
54///
55/// Pushed as one value rather than one setter per fact. These are read
56/// together, and a container that updates one while leaving another stale
57/// describes a dock that cannot exist — a group beside siblings that still
58/// reports itself alone. Choosing a constructor forces the container kind to be
59/// stated; anything a constructor does not grant stays off, so a container
60/// that forgets something gets a group that does less rather than more.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct TabGroupConstraints {
63    alone: bool,
64    dock_locked: bool,
65    collapsed: bool,
66    closable: bool,
67}
68
69impl TabGroupConstraints {
70    /// A group with nowhere to go: locked, alone, unclosable. What a group
71    /// starts as, before any container has placed it.
72    pub fn sealed() -> Self {
73        Self {
74            alone: true,
75            dock_locked: true,
76            collapsed: false,
77            closable: false,
78        }
79    }
80
81    /// A group in a split layout. `alone` when nothing sits beside it, which
82    /// is what stops its last visible panel being dragged out and leaving the
83    /// dock empty.
84    pub fn in_split(alone: bool) -> Self {
85        Self {
86            alone,
87            dock_locked: false,
88            collapsed: false,
89            closable: true,
90        }
91    }
92
93    /// Whether the dock as a whole forbids rearranging.
94    pub fn dock_locked(mut self, dock_locked: bool) -> Self {
95        self.dock_locked = dock_locked;
96        self
97    }
98
99    /// Whether the group is folded away to a strip of tabs with no content.
100    pub fn collapsed(mut self, collapsed: bool) -> Self {
101        self.collapsed = collapsed;
102        self
103    }
104
105    /// Whether the container allows this group's panels to be closed at all.
106    /// A dock's last group sets this `false` so the dock cannot be emptied.
107    pub fn closable(mut self, closable: bool) -> Self {
108        self.closable = closable;
109        self
110    }
111
112    /// Whether nothing sits beside this group in its tree.
113    pub fn is_alone(&self) -> bool {
114        self.alone
115    }
116
117    /// Whether the group's place in the dock is fixed.
118    ///
119    /// The dock-wide lock is the whole of it. A tab group only ever sits
120    /// inside a split, so there is no second way for a container to pin one
121    /// down, and no separate `is_dock_locked` reader that would answer
122    /// identically.
123    pub fn is_locked(&self) -> bool {
124        self.dock_locked
125    }
126
127    pub fn is_collapsed(&self) -> bool {
128        self.collapsed
129    }
130
131    pub fn is_closable(&self) -> bool {
132        self.closable
133    }
134}
135
136/// A tab group's behavior, with no appearance of its own.
137///
138/// It owns the panel list mirrored from the layout tree, the displayed index,
139/// the focus handle, drag and drop hit state, and the zoom flag. Everything
140/// visible is produced by the [`TabGroupRenderer`] the host installs.
141///
142/// The group holds no handle on its container. What the container knows — the
143/// facts in [`TabGroupConstraints`] — is pushed in, and what the group needs
144/// done is emitted as a [`TabGroupEvent`]. That keeps a group constructible,
145/// and testable, on its own.
146pub struct TabGroup {
147    node: NodeId,
148    /// Handed to the callbacks in [`TabGroupContext`], which are built from a
149    /// plain `&App` and so cannot ask for it.
150    this: WeakEntity<Self>,
151    panels: Vec<Arc<dyn PanelView>>,
152    active_ix: usize,
153    zoomed: bool,
154    constraints: TabGroupConstraints,
155    focus_handle: FocusHandle,
156    active: ActiveTracker,
157    drop_indicator: Option<DropIndicator>,
158    renderer: Rc<dyn TabGroupRenderer>,
159}
160
161impl TabGroup {
162    /// Only a container builds groups: a group is the entity mirror of one
163    /// `Tabs` node, and it is created when that node first appears in the
164    /// tree. `DockArea` is the only caller outside tests.
165    pub(crate) fn new(node: NodeId, _window: &mut Window, cx: &mut Context<Self>) -> Self {
166        Self {
167            node,
168            this: cx.weak_entity(),
169            panels: Vec::new(),
170            active_ix: 0,
171            zoomed: false,
172            // A group that no container has placed yet can do nothing.
173            constraints: TabGroupConstraints::sealed(),
174            focus_handle: cx.focus_handle(),
175            active: ActiveTracker::default(),
176            drop_indicator: None,
177            renderer: Rc::new(BareTabGroup),
178        }
179    }
180
181    pub fn with_renderer(mut self, renderer: Rc<dyn TabGroupRenderer>) -> Self {
182        self.renderer = renderer;
183        self
184    }
185
186    /// The `Tabs` node this group mirrors.
187    pub fn node(&self) -> NodeId {
188        self.node
189    }
190
191    pub fn panels(&self) -> &[Arc<dyn PanelView>] {
192        &self.panels
193    }
194
195    pub fn active_ix(&self) -> usize {
196        self.active_ix
197    }
198
199    /// The panel currently on screen, which is the displayed tab unless it has
200    /// gone invisible, in which case rendering falls back to the first visible
201    /// panel.
202    pub fn active_panel(&self, cx: &App) -> Option<Arc<dyn PanelView>> {
203        match self.panels.get(self.active_ix) {
204            Some(panel) if panel.visible(cx) => Some(panel.clone()),
205            Some(_) => self.visible_panels(cx).next(),
206            None => None,
207        }
208    }
209
210    pub fn is_zoomed(&self) -> bool {
211        self.zoomed
212    }
213
214    pub fn is_collapsed(&self) -> bool {
215        self.constraints.is_collapsed()
216    }
217
218    /// Whether closing this group's displayed panel is allowed at all.
219    ///
220    /// Mirrors the old `TabPanel::closable`: the container must permit it, the
221    /// group must have somewhere to go, and the displayed panel must itself be
222    /// closable.
223    pub fn is_closable(&self, cx: &App) -> bool {
224        self.constraints.is_closable()
225            && self.draggable(cx)
226            && self
227                .active_panel(cx)
228                .is_some_and(|panel| panel.closable(cx))
229    }
230
231    /// Display `ix`, if it names a tab that is not already displayed.
232    pub fn select_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
233        if ix >= self.panels.len() || ix == self.active_ix {
234            return;
235        }
236
237        self.active_ix = ix;
238        self.focus_active_panel(window, cx);
239        self.schedule_active_sync(window, cx);
240        cx.emit(TabGroupEvent::ActiveChanged { ix });
241        cx.notify();
242    }
243
244    /// Ask the container to close `panel`. Nothing happens for a panel that is
245    /// not in this group, or when either the group or the panel refuses.
246    pub fn close_panel(&mut self, panel: PanelId, cx: &mut Context<Self>) {
247        if !self.constraints.is_closable() {
248            return;
249        }
250        // A dock's last group has nowhere to go and must stay.
251        if !self.draggable(cx) {
252            return;
253        }
254
255        let closable = self
256            .panels
257            .iter()
258            .any(|candidate| candidate.panel_id(cx) == panel && candidate.closable(cx));
259        if !closable {
260            return;
261        }
262
263        cx.emit(TabGroupEvent::ClosePanel { panel });
264        cx.notify();
265    }
266
267    pub fn toggle_zoom(&mut self, window: &mut Window, cx: &mut Context<Self>) {
268        self.set_zoomed(!self.zoomed, window, cx);
269    }
270
271    /// A snapshot of everything a skin needs to draw this group.
272    pub fn context(&self, cx: &App) -> TabGroupContext {
273        let group = self.this.clone();
274
275        TabGroupContext {
276            node: self.node,
277            panels: self.panels.clone(),
278            active_panel: self.active_panel(cx),
279            active_ix: self.active_ix,
280            zoomed: self.zoomed,
281            collapsed: self.constraints.is_collapsed(),
282            closable: self.is_closable(cx),
283            locked: self.is_locked(),
284            draggable: self.draggable(cx),
285            droppable: self.droppable(),
286            // A stale indicator would otherwise outlive a drag that was
287            // cancelled while hovering this group.
288            drop_indicator: cx
289                .has_active_drag()
290                .then_some(self.drop_indicator)
291                .flatten(),
292            on_select_tab: {
293                let group = group.clone();
294                Rc::new(move |ix, window, cx| {
295                    _ = group.update(cx, |group, cx| group.select_tab(ix, window, cx));
296                })
297            },
298            on_close: {
299                let group = group.clone();
300                Rc::new(move |panel, _, cx| {
301                    _ = group.update(cx, |group, cx| group.close_panel(panel, cx));
302                })
303            },
304            on_toggle_zoom: {
305                let group = group.clone();
306                Rc::new(move |window, cx| {
307                    _ = group.update(cx, |group, cx| group.toggle_zoom(window, cx));
308                })
309            },
310            on_drop_panel: {
311                let group = group.clone();
312                Rc::new(move |drag, ix, activate, _, cx| {
313                    _ = group.update(cx, |group, cx| group.on_drop(&drag, ix, activate, cx));
314                })
315            },
316            on_drop_item: Rc::new(move |item, placement, _, cx| {
317                _ = group.update(cx, |group, cx| group.emit_drag_drop(&item, placement, cx));
318            }),
319        }
320    }
321}
322
323/// What the container pushes into a group, and reads back out of it.
324///
325/// `DockArea` is the only caller outside tests.
326impl TabGroup {
327    /// Mirror one `Tabs` node's membership and displayed index into this
328    /// group. `on_added_to` fires for arrivals; departures are silent here
329    /// because only the container can tell a move from a removal.
330    pub(crate) fn sync_from_tree(
331        &mut self,
332        panels: Vec<Arc<dyn PanelView>>,
333        active_ix: usize,
334        window: &mut Window,
335        cx: &mut Context<Self>,
336    ) {
337        let group = cx.weak_entity();
338        let existing: Vec<PanelId> = self.panels.iter().map(|panel| panel.panel_id(cx)).collect();
339        for panel in panels.iter() {
340            if !existing.contains(&panel.panel_id(cx)) {
341                panel.on_added_to(group.clone(), window, cx);
342            }
343        }
344
345        self.panels = panels;
346        self.active_ix = active_ix.min(self.panels.len().saturating_sub(1));
347        self.schedule_active_sync(window, cx);
348        cx.notify();
349    }
350
351    /// Replace everything the container knows about this group's place in the
352    /// dock, in one call.
353    ///
354    /// One value rather than a setter per fact, because these are read
355    /// together and a container that updates one while leaving another stale
356    /// describes a dock that cannot exist — a group beside siblings that still
357    /// reports itself alone.
358    pub(crate) fn set_constraints(
359        &mut self,
360        constraints: TabGroupConstraints,
361        window: &mut Window,
362        cx: &mut Context<Self>,
363    ) {
364        if self.constraints == constraints {
365            return;
366        }
367
368        // Collapsing takes the displayed panel off screen, which the
369        // active-state contract counts as no panel being displayed.
370        let collapse_changed = self.constraints.is_collapsed() != constraints.is_collapsed();
371        self.constraints = constraints;
372        if collapse_changed {
373            self.schedule_active_sync(window, cx);
374        }
375        cx.notify();
376    }
377
378    /// Zoom this group in or out, in full: the flag flips, the displayed
379    /// panel is told, and the container is asked to install or clear the
380    /// zoomed view.
381    ///
382    /// Zoom is the group's own state rather than the container's, but the
383    /// container drives it too when it installs or clears a zoomed view — and
384    /// it goes through this same method, so a group cannot end up flagged
385    /// zoomed while the container shows something else.
386    ///
387    /// Zooming *in* is refused, leaving the flag alone, when there is no
388    /// displayed panel or that panel is not zoomable — the early return the
389    /// old `TabPanel::on_action_toggle_zoom` made on `zoomable(cx).is_none()`.
390    /// Zooming *out* is never refused: a group that became unzoomable while
391    /// zoomed still has to be able to give the dock back.
392    pub(crate) fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context<Self>) {
393        if self.zoomed == zoomed {
394            return;
395        }
396        let panel = self.active_panel(cx);
397        if zoomed && !panel.as_ref().is_some_and(|panel| panel.zoomable(cx)) {
398            return;
399        }
400
401        self.zoomed = zoomed;
402        cx.emit(if zoomed {
403            TabGroupEvent::ZoomIn
404        } else {
405            TabGroupEvent::ZoomOut
406        });
407
408        // Delivered outside this update so a `set_zoomed` handler may call
409        // back into the group. The old `TabPanel` sent this to itself, where
410        // `Panel::set_zoomed` defaulted to a no-op, so no panel ever heard it.
411        if let Some(panel) = panel {
412            cx.spawn_in(window, async move |_, cx| {
413                _ = cx.update(|window, cx| panel.set_zoomed(zoomed, window, cx));
414            })
415            .detach();
416        }
417        cx.notify();
418    }
419
420    /// What this group last told `panel` about being active, for handing to
421    /// [`Self::seed_active`] on the group it is moving to.
422    pub(crate) fn last_notified_active(&self, panel: PanelId) -> Option<bool> {
423        self.active.last_notified(panel)
424    }
425
426    /// Record what an arriving panel already believes, so a move between
427    /// groups does not read as a fresh activation.
428    pub(crate) fn seed_active(&mut self, panel: PanelId, active: bool) {
429        self.active.seed(panel, active);
430    }
431}
432
433impl TabGroup {
434    /// Every visible panel, in tab order.
435    fn visible_panels<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = Arc<dyn PanelView>> + 'a {
436        self.panels
437            .iter()
438            .filter(|panel| panel.visible(cx))
439            .cloned()
440    }
441
442    /// A locked group cannot be rearranged. Zooming locks it too: a zoomed
443    /// group is the only thing on screen, so there is nowhere to drop.
444    fn is_locked(&self) -> bool {
445        self.constraints.is_locked() || self.zoomed
446    }
447
448    /// True when this group holds the last visible panel that anything could
449    /// be rearranged around. Only visible panels count, so a hidden panel does
450    /// not keep the last visible one draggable and leave the dock empty.
451    fn is_last_panel(&self, cx: &App) -> bool {
452        self.constraints.is_alone() && self.visible_panels(cx).count() <= 1
453    }
454
455    fn draggable(&self, cx: &App) -> bool {
456        !self.is_locked() && !self.is_last_panel(cx)
457    }
458
459    fn droppable(&self) -> bool {
460        !self.is_locked()
461    }
462
463    fn focus_active_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
464        if let Some(panel) = self.active_panel(cx) {
465            panel.focus_handle(cx).focus(window, cx);
466        }
467    }
468
469    /// Queue one reconcile per frame that notifies panels of their frame-end
470    /// net active state. A spawned task, not `defer`, so it runs after every
471    /// same-frame mutation including a deferred collapse.
472    fn schedule_active_sync(&mut self, window: &mut Window, cx: &mut Context<Self>) {
473        if !self.active.schedule_sync() {
474            return;
475        }
476
477        cx.spawn_in(window, async move |group, cx| {
478            _ = cx.update(|window, cx| {
479                let Ok(changes) = group.update(cx, |group, cx| group.reconcile_active(cx)) else {
480                    return;
481                };
482                // Dispatched outside the group's update so a `set_active`
483                // handler may call back into it without panicking.
484                for (panel, active) in changes {
485                    panel.set_active(active, window, cx);
486                }
487            });
488        })
489        .detach();
490    }
491
492    /// The deliveries this frame owes, deactivations first. The displayed slot
493    /// is `active_ix` even when the panel there is invisible: rendering falls
494    /// back to another panel, but the active-state contract does not.
495    fn reconcile_active(&mut self, cx: &App) -> Vec<(Arc<dyn PanelView>, bool)> {
496        self.active.sync_finished();
497
498        let ids: Vec<PanelId> = self.panels.iter().map(|panel| panel.panel_id(cx)).collect();
499        let displayed = match self.constraints.is_collapsed() {
500            true => None,
501            false => ids.get(self.active_ix).copied(),
502        };
503
504        self.active
505            .reconcile(&ids, displayed)
506            .into_iter()
507            .filter_map(|(id, active)| {
508                ids.iter()
509                    .position(|candidate| *candidate == id)
510                    .map(|ix| (self.panels[ix].clone(), active))
511            })
512            .collect()
513    }
514
515    /// Where a dragged panel would land, tracked while it moves over the
516    /// group's content.
517    fn on_panel_drag_move(
518        &mut self,
519        drag: &DragMoveEvent<DragPanel>,
520        _: &mut Window,
521        cx: &mut Context<Self>,
522    ) {
523        let bounds = drag.bounds;
524        if !bounds.contains(&drag.event.position) {
525            self.clear_drop_indicator(cx);
526            return;
527        }
528
529        let placement = split_placement_at(bounds, drag.event.position);
530        let dragged = drag.drag(cx);
531        // The placeholder flies in from wherever the preview currently is.
532        let source = DropPlaceholderBounds::new(
533            drag.event.position - dragged.drag_offset() - bounds.origin,
534            dragged.preview_size(),
535        );
536
537        self.sync_drop_placeholder(bounds, placement, dragged.drag_session_id(), source, cx);
538    }
539
540    /// Same as [`Self::on_panel_drag_move`], for a host-owned drag item.
541    ///
542    /// A drag item has no dragged tab to fly the placeholder in from, so it
543    /// starts at its resting position and only animates between placements.
544    fn on_item_drag_move(
545        &mut self,
546        drag: &DragMoveEvent<AnyDrag>,
547        _: &mut Window,
548        cx: &mut Context<Self>,
549    ) {
550        let bounds = drag.bounds;
551        if !bounds.contains(&drag.event.position) {
552            self.clear_drop_indicator(cx);
553            return;
554        }
555
556        let placement = split_placement_at(bounds, drag.event.position);
557        let source = DropPlaceholderBounds::for_placement(bounds, placement);
558
559        self.sync_drop_placeholder(bounds, placement, ITEM_DRAG_SESSION_ID, source, cx);
560    }
561
562    fn sync_drop_placeholder(
563        &mut self,
564        bounds: Bounds<Pixels>,
565        placement: Option<Placement>,
566        drag_session_id: u64,
567        source: DropPlaceholderBounds,
568        cx: &mut Context<Self>,
569    ) {
570        let to = DropPlaceholderBounds::for_placement(bounds, placement);
571
572        let restart = self.drop_indicator.is_none_or(|indicator| {
573            indicator.drag_session_id() != drag_session_id || indicator.placement() != placement
574        });
575
576        let (from, epoch) = match (restart, self.drop_indicator) {
577            (false, Some(indicator)) => (indicator.from(), indicator.epoch()),
578            (_, Some(indicator)) => {
579                let from = match indicator.drag_session_id() == drag_session_id {
580                    true => indicator.to(),
581                    false => source,
582                };
583                (from, indicator.epoch().wrapping_add(1))
584            }
585            (_, None) => (source, 0),
586        };
587
588        self.drop_indicator = Some(DropIndicator::new(
589            bounds,
590            placement,
591            from,
592            to,
593            drag_session_id,
594            epoch,
595        ));
596        cx.notify();
597    }
598
599    fn clear_drop_indicator(&mut self, cx: &mut Context<Self>) {
600        if self.drop_indicator.take().is_some() {
601            cx.notify();
602        }
603    }
604
605    /// Report a host-owned drag landing on this group. `placement` is `None`
606    /// to merge into the tab group instead of splitting.
607    fn emit_drag_drop(
608        &mut self,
609        item: &AnyDrag,
610        placement: Option<Placement>,
611        cx: &mut Context<Self>,
612    ) {
613        self.drop_indicator = None;
614        cx.emit(TabGroupEvent::DragDrop {
615            item: item.clone(),
616            target: DropTarget::new(self.node, placement),
617        });
618        cx.notify();
619    }
620
621    /// Resolve where a dropped panel lands and report it as one move.
622    ///
623    /// `ix` names a tab slot, which the tab bar supplies and the content area
624    /// does not; a slot always merges, so it overrides any split the hovering
625    /// drag had resolved. `activate` decides whether the arriving panel
626    /// becomes the displayed tab.
627    fn on_drop(
628        &mut self,
629        drag: &DragPanel,
630        ix: Option<usize>,
631        activate: bool,
632        cx: &mut Context<Self>,
633    ) {
634        let indicator = self.drop_indicator.take();
635        let placement = match ix {
636            Some(_) => None,
637            None => indicator.and_then(|indicator| indicator.placement()),
638        };
639
640        // Dropping a panel back onto its own group is a move only when it
641        // splits out of a group holding more than itself, or when it lands on
642        // a specific tab slot.
643        if drag.source() == self.node
644            && ix.is_none()
645            && (placement.is_none() || self.panels.len() == 1)
646        {
647            cx.notify();
648            return;
649        }
650
651        let target = match placement {
652            Some(placement) => InsertTarget::Split {
653                node: self.node,
654                placement,
655                size: None,
656            },
657            None => InsertTarget::Tabs {
658                node: self.node,
659                ix,
660                activate,
661            },
662        };
663
664        cx.emit(TabGroupEvent::Drop {
665            panel: drag.panel(),
666            source: drag.source(),
667            target,
668        });
669        cx.notify();
670    }
671}
672
673impl EventEmitter<TabGroupEvent> for TabGroup {}
674
675impl Focusable for TabGroup {
676    fn focus_handle(&self, cx: &App) -> FocusHandle {
677        match self.active_panel(cx) {
678            Some(panel) => panel.focus_handle(cx),
679            None => self.focus_handle.clone(),
680        }
681    }
682}
683
684impl Render for TabGroup {
685    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
686        let context = self.context(cx);
687        let renderer = self.renderer.clone();
688        let focus_handle = self.focus_handle(cx);
689        let droppable = context.droppable;
690        let indicator = context.drop_indicator;
691
692        renderer
693            .frame(&context, window, cx)
694            .test_support()
695            // Structure, applied around whatever the renderer returns.
696            //
697            // A column, and not a `div`: gpui's default display is Block, and
698            // in block layout a child's `flex_grow` is ignored -- the content
699            // region below the tab bar resolves to zero height, because its
700            // only descendant is the panel view, positioned absolutely and
701            // contributing no content height. So a renderer that returned a
702            // plain frame got a group that drew its tabs and nothing else, at
703            // whatever width its tabs happened to be.
704            .flex()
705            .flex_col()
706            .size_full()
707            .overflow_hidden()
708            .track_focus(&focus_handle)
709            .tab_group()
710            .child(renderer.render_tab_bar(&context, window, cx))
711            .child(
712                renderer
713                    .content_frame(&context, window, cx)
714                    .test_support()
715                    // The region below the tab bar takes the rest of the
716                    // group -- except in a collapsed one, which is a strip of
717                    // tabs with no content and must claim no space at all.
718                    .flex()
719                    .flex_col()
720                    .when(!context.is_collapsed(), |this| this.flex_1())
721                    // A flex item's `min-height` is `auto`, so a column that
722                    // grows to fill the group is still floored by the height
723                    // its content wants. A panel holding a virtualized list
724                    // measured itself against every row rather than the region
725                    // it was given: the clip was right, so it looked correct,
726                    // and the list built rows nobody could see. Flooring it at
727                    // zero lets the region win.
728                    .min_h(px(0.))
729                    .overflow_hidden()
730                    // Both drag kinds hang off `droppable` alone.
731                    .when(droppable, |this| {
732                        this.on_drag_move(cx.listener(Self::on_panel_drag_move))
733                            .on_drop(cx.listener(|this, drag: &DragPanel, _, cx| {
734                                this.on_drop(drag, None, true, cx)
735                            }))
736                            .on_drag_move(cx.listener(Self::on_item_drag_move))
737                            .on_drop(cx.listener(|this, item: &AnyDrag, _, cx| {
738                                let placement = this
739                                    .drop_indicator
740                                    .and_then(|indicator| indicator.placement());
741                                this.emit_drag_drop(item, placement, cx);
742                            }))
743                    })
744                    .map(|this| match context.active_panel.as_ref() {
745                        Some(panel) => this.child(renderer.render_active_panel(
746                            panel.view(),
747                            &context,
748                            window,
749                            cx,
750                        )),
751                        None => this.children(renderer.render_empty(&context, window, cx)),
752                    })
753                    .when_some(indicator, |this, indicator| {
754                        this.children(renderer.render_drop_indicator(indicator, window, cx))
755                    }),
756            )
757    }
758}
759
760type SelectTabHandler = Rc<dyn Fn(usize, &mut Window, &mut App)>;
761type ClosePanelHandler = Rc<dyn Fn(PanelId, &mut Window, &mut App)>;
762type ToggleZoomHandler = Rc<dyn Fn(&mut Window, &mut App)>;
763type DropPanelHandler = Rc<dyn Fn(DragPanel, Option<usize>, bool, &mut Window, &mut App)>;
764type DropItemHandler = Rc<dyn Fn(AnyDrag, Option<Placement>, &mut Window, &mut App)>;
765
766/// What a skin needs to draw a tab group, and the callbacks it invokes rather
767/// than reimplementing behavior.
768#[derive(Clone)]
769pub struct TabGroupContext {
770    node: NodeId,
771    panels: Vec<Arc<dyn PanelView>>,
772    active_panel: Option<Arc<dyn PanelView>>,
773    active_ix: usize,
774    zoomed: bool,
775    collapsed: bool,
776    locked: bool,
777    draggable: bool,
778    droppable: bool,
779    closable: bool,
780    drop_indicator: Option<DropIndicator>,
781    on_select_tab: SelectTabHandler,
782    on_close: ClosePanelHandler,
783    on_toggle_zoom: ToggleZoomHandler,
784    on_drop_panel: DropPanelHandler,
785    on_drop_item: DropItemHandler,
786}
787
788impl TabGroupContext {
789    /// The `Tabs` node this group mirrors, for a skin that needs to name the
790    /// group in a drag payload or a drop target.
791    pub fn node(&self) -> NodeId {
792        self.node
793    }
794
795    /// Every panel in the group, in tab order — visible or not. A skin filters
796    /// with [`PanelView::visible`] when it draws.
797    pub fn panels(&self) -> &[Arc<dyn PanelView>] {
798        &self.panels
799    }
800
801    pub fn active_ix(&self) -> usize {
802        self.active_ix
803    }
804
805    /// The panel on screen, which is the displayed tab unless that panel has
806    /// gone invisible.
807    pub fn active_panel(&self) -> Option<&Arc<dyn PanelView>> {
808        self.active_panel.as_ref()
809    }
810
811    pub fn drop_indicator(&self) -> Option<DropIndicator> {
812        self.drop_indicator
813    }
814
815    pub fn is_zoomed(&self) -> bool {
816        self.zoomed
817    }
818
819    pub fn is_collapsed(&self) -> bool {
820        self.collapsed
821    }
822
823    /// Whether closing the displayed panel is allowed at all, so a skin knows
824    /// whether to offer a Close control.
825    pub fn is_closable(&self) -> bool {
826        self.closable
827    }
828
829    pub fn is_locked(&self) -> bool {
830        self.locked
831    }
832
833    pub fn is_draggable(&self) -> bool {
834        self.draggable
835    }
836
837    pub fn is_droppable(&self) -> bool {
838        self.droppable
839    }
840
841    pub fn select_tab(&self, ix: usize, window: &mut Window, cx: &mut App) {
842        (self.on_select_tab)(ix, window, cx);
843    }
844
845    pub fn close(&self, panel: PanelId, window: &mut Window, cx: &mut App) {
846        (self.on_close)(panel, window, cx);
847    }
848
849    pub fn toggle_zoom(&self, window: &mut Window, cx: &mut App) {
850        (self.on_toggle_zoom)(window, cx);
851    }
852
853    /// The drag payload for the tab at `ix`, for a skin wiring `on_drag` onto
854    /// its tabs. `None` when `ix` names no panel.
855    pub fn drag_panel(&self, ix: usize, cx: &App) -> Option<DragPanel> {
856        self.panels
857            .get(ix)
858            .map(|panel| DragPanel::new(panel.panel_id(cx), self.node))
859    }
860
861    /// A panel dropped on the tab bar. `ix` names the slot it lands in, or
862    /// `None` to append; `activate` decides whether it becomes displayed.
863    pub fn drop_panel(
864        &self,
865        drag: DragPanel,
866        ix: Option<usize>,
867        activate: bool,
868        window: &mut Window,
869        cx: &mut App,
870    ) {
871        (self.on_drop_panel)(drag, ix, activate, window, cx);
872    }
873
874    /// A host-owned drag dropped on the tab bar. `placement` is `None` to
875    /// merge into this group rather than split beside it.
876    pub fn drop_item(
877        &self,
878        item: AnyDrag,
879        placement: Option<Placement>,
880        window: &mut Window,
881        cx: &mut App,
882    ) {
883        (self.on_drop_item)(item, placement, window, cx);
884    }
885}
886
887/// Appearance for a tab group. Base draws none of it.
888///
889/// The two frame hooks return the element itself rather than wrapping one,
890/// because base attaches focus, keyboard grouping, and drop handling to the
891/// very elements the skin styles: a wrapper would put the hit area and the
892/// painted area on different elements.
893#[allow(unused_variables)]
894pub trait TabGroupRenderer: 'static {
895    /// The group's outer frame, which base tracks focus on.
896    ///
897    /// Identified rather than plain, so a skin can add a role, a tooltip, or
898    /// scroll tracking; `Stateful<Div>` does everything base needs from it.
899    /// Appearance only. The group is laid out as a column that fills its slot
900    /// around whatever this returns, because a group that does not is a strip
901    /// of tabs with no content under it.
902    fn frame(&self, group: &TabGroupContext, window: &mut Window, cx: &mut App) -> Stateful<Div> {
903        div().id("tab-group")
904    }
905
906    /// The region below the tab bar, which base installs drop handling on.
907    fn content_frame(
908        &self,
909        group: &TabGroupContext,
910        window: &mut Window,
911        cx: &mut App,
912    ) -> Stateful<Div> {
913        div().id("tab-group-content")
914    }
915
916    fn render_tab_bar(
917        &self,
918        group: &TabGroupContext,
919        window: &mut Window,
920        cx: &mut App,
921    ) -> AnyElement;
922
923    /// How the displayed panel's view is placed in the content region. The
924    /// skin receives the view rather than an element so it can decide how the
925    /// view is cached and stretched.
926    fn render_active_panel(
927        &self,
928        panel: AnyView,
929        group: &TabGroupContext,
930        window: &mut Window,
931        cx: &mut App,
932    ) -> AnyElement {
933        panel.into_any_element()
934    }
935
936    fn render_drop_indicator(
937        &self,
938        indicator: DropIndicator,
939        window: &mut Window,
940        cx: &mut App,
941    ) -> Option<AnyElement> {
942        None
943    }
944
945    fn render_empty(
946        &self,
947        group: &TabGroupContext,
948        window: &mut Window,
949        cx: &mut App,
950    ) -> Option<AnyElement> {
951        None
952    }
953}
954
955/// The renderer a group starts with: the displayed panel and nothing else.
956pub(crate) struct BareTabGroup;
957
958impl TabGroupRenderer for BareTabGroup {
959    fn render_tab_bar(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> AnyElement {
960        Empty.into_any_element()
961    }
962}
963
964#[cfg(test)]
965mod tests {
966    use std::cell::RefCell;
967
968    use gpui::{
969        AppContext as _, Entity, Modifiers, MouseButton, StatefulInteractiveElement as _,
970        TestAppContext, VisualTestContext, point, px, size,
971    };
972
973    use super::*;
974    use crate::dock::test_support::{
975        PanelSignal, build_group, drain, drain_active, log_of, panel_id,
976    };
977
978    /// The node `build_group` gives its group.
979    fn group_node() -> NodeId {
980        NodeId::from_u64(1)
981    }
982
983    fn elsewhere() -> NodeId {
984        NodeId::from_u64(7)
985    }
986
987    fn content_bounds() -> Bounds<Pixels> {
988        Bounds {
989            origin: point(px(0.), px(0.)),
990            size: size(px(400.), px(300.)),
991        }
992    }
993
994    /// Collect the group's outgoing intents as readable strings, so an
995    /// assertion reads as the sentence the event means.
996    fn record_events(
997        group: &Entity<TabGroup>,
998        cx: &mut VisualTestContext,
999    ) -> Rc<RefCell<Vec<String>>> {
1000        let events: Rc<RefCell<Vec<String>>> = Rc::default();
1001        let sink = events.clone();
1002        cx.update(|_, cx| {
1003            cx.subscribe(group, move |_, event: &TabGroupEvent, _| {
1004                sink.borrow_mut().push(describe(event));
1005            })
1006            .detach();
1007        });
1008        events
1009    }
1010
1011    fn describe(event: &TabGroupEvent) -> String {
1012        match event {
1013            TabGroupEvent::Drop {
1014                panel,
1015                source,
1016                target,
1017            } => match target {
1018                InsertTarget::Tabs { node, ix, activate } => format!(
1019                    "drop panel {} from {} into tabs {} at {:?} activate={}",
1020                    panel.as_u64(),
1021                    source.as_u64(),
1022                    node.as_u64(),
1023                    ix,
1024                    activate
1025                ),
1026                InsertTarget::Split {
1027                    node, placement, ..
1028                } => format!(
1029                    "drop panel {} from {} split {} {}",
1030                    panel.as_u64(),
1031                    source.as_u64(),
1032                    node.as_u64(),
1033                    placement
1034                ),
1035            },
1036            TabGroupEvent::DragDrop { target, .. } => format!(
1037                "item onto {} at {:?}",
1038                target.node().as_u64(),
1039                target.placement()
1040            ),
1041            TabGroupEvent::ClosePanel { panel } => format!("close {}", panel.as_u64()),
1042            TabGroupEvent::ActiveChanged { ix } => format!("active {ix}"),
1043            TabGroupEvent::ZoomIn => "zoom in".into(),
1044            TabGroupEvent::ZoomOut => "zoom out".into(),
1045        }
1046    }
1047
1048    #[gpui::test]
1049    fn a_group_announces_its_first_panel_active(cx: &mut TestAppContext) {
1050        let log = log_of();
1051        let (group, panels, cx) = build_group(&log, &["a"], cx);
1052        cx.run_until_parked();
1053
1054        assert_eq!(drain_active(&log), vec![("a", true)]);
1055        let _ = (group, panels);
1056    }
1057
1058    #[gpui::test]
1059    fn selecting_another_tab_deactivates_then_activates(cx: &mut TestAppContext) {
1060        let log = log_of();
1061        let (group, _panels, cx) = build_group(&log, &["a", "b"], cx);
1062        cx.run_until_parked();
1063        drain_active(&log);
1064
1065        cx.update(|window, cx| {
1066            group.update(cx, |group, cx| group.select_tab(1, window, cx));
1067        });
1068        cx.run_until_parked();
1069
1070        assert_eq!(drain_active(&log), vec![("a", false), ("b", true)]);
1071    }
1072
1073    #[gpui::test]
1074    fn the_context_snapshots_the_group_state(cx: &mut TestAppContext) {
1075        let log = log_of();
1076        let (group, _panels, cx) = build_group(&log, &["a", "b"], cx);
1077
1078        let seen = cx.update(|_, cx| {
1079            group.update(cx, |group, cx| {
1080                let context = group.context(cx);
1081                (
1082                    context.panels().len(),
1083                    context.active_ix(),
1084                    context.is_zoomed(),
1085                )
1086            })
1087        });
1088
1089        assert_eq!(seen, (2, 0, false));
1090    }
1091
1092    /// A panel hidden while it holds the displayed slot is still the panel
1093    /// told it is active; only what gets drawn falls back to a visible one.
1094    #[gpui::test]
1095    fn a_hidden_displayed_panel_is_still_the_active_one(cx: &mut TestAppContext) {
1096        let log = log_of();
1097        let (group, panels, cx) = build_group(&log, &["a", "b"], cx);
1098        cx.update(|_, cx| {
1099            panels[0].update(cx, |panel, cx| panel.set_visible(false, cx));
1100        });
1101        cx.run_until_parked();
1102
1103        assert_eq!(drain_active(&log), vec![("a", true)]);
1104        assert_eq!(
1105            cx.update(|_, cx| group.read(cx).active_panel(cx).unwrap().panel_name(cx)),
1106            "b",
1107            "rendering falls back to the first visible panel"
1108        );
1109    }
1110
1111    /// `on_removed` is a departing panel's deactivation signal, so the group
1112    /// must not also announce `false` to it.
1113    #[gpui::test]
1114    fn a_panel_dropped_from_the_group_is_never_told_it_went_inactive(cx: &mut TestAppContext) {
1115        let log = log_of();
1116        let (group, panels, cx) = build_group(&log, &["a", "b"], cx);
1117        cx.run_until_parked();
1118        drain(&log);
1119
1120        cx.update(|window, cx| {
1121            let remaining: Vec<Arc<dyn PanelView>> = vec![Arc::new(panels[1].clone())];
1122            group.update(cx, |group, cx| {
1123                group.sync_from_tree(remaining, 0, window, cx)
1124            });
1125        });
1126        cx.run_until_parked();
1127
1128        assert_eq!(drain_active(&log), vec![("b", true)]);
1129    }
1130
1131    #[gpui::test]
1132    fn selecting_a_tab_that_is_not_there_changes_nothing(cx: &mut TestAppContext) {
1133        let log = log_of();
1134        let (group, _panels, cx) = build_group(&log, &["a", "b"], cx);
1135        cx.run_until_parked();
1136        drain_active(&log);
1137        let events = record_events(&group, cx);
1138
1139        cx.update(|window, cx| {
1140            group.update(cx, |group, cx| group.select_tab(5, window, cx));
1141        });
1142        cx.run_until_parked();
1143
1144        assert_eq!(cx.update(|_, cx| group.read(cx).active_ix()), 0);
1145        assert!(events.borrow().is_empty());
1146        assert!(drain_active(&log).is_empty());
1147    }
1148
1149    #[gpui::test]
1150    fn a_locked_group_can_be_neither_dragged_nor_dropped_into(cx: &mut TestAppContext) {
1151        let log = log_of();
1152        let (group, _panels, cx) = build_group(&log, &["a", "b"], cx);
1153
1154        cx.update(|window, cx| {
1155            group.update(cx, |group, cx| {
1156                group.set_constraints(TabGroupConstraints::in_split(false), window, cx)
1157            })
1158        });
1159        let unlocked = cx.update(|_, cx| {
1160            let context = group.read(cx).context(cx);
1161            (context.is_draggable(), context.is_droppable())
1162        });
1163        cx.update(|window, cx| {
1164            group.update(cx, |group, cx| {
1165                group.set_constraints(
1166                    TabGroupConstraints::in_split(false).dock_locked(true),
1167                    window,
1168                    cx,
1169                )
1170            })
1171        });
1172        let locked = cx.update(|_, cx| {
1173            let context = group.read(cx).context(cx);
1174            (context.is_draggable(), context.is_droppable())
1175        });
1176
1177        assert_eq!(unlocked, (true, true));
1178        assert_eq!(locked, (false, false));
1179    }
1180
1181    /// Zooming is a lock of its own: a zoomed group fills the dock, so there
1182    /// is nothing beside it to drop against.
1183    #[gpui::test]
1184    fn a_zoomed_group_takes_no_drops(cx: &mut TestAppContext) {
1185        let log = log_of();
1186        let (group, _panels, cx) = build_group(&log, &["a", "b"], cx);
1187
1188        cx.update(|window, cx| {
1189            group.update(cx, |group, cx| {
1190                group.set_constraints(TabGroupConstraints::in_split(false), window, cx);
1191                group.set_zoomed(true, window, cx);
1192            })
1193        });
1194
1195        assert!(!cx.update(|_, cx| group.read(cx).context(cx).is_droppable()));
1196    }
1197
1198    /// Dragging the last visible panel out of the only group would leave the
1199    /// dock empty and undroppable, so it is refused.
1200    #[gpui::test]
1201    fn the_only_groups_last_visible_panel_cannot_be_dragged_out(cx: &mut TestAppContext) {
1202        let log = log_of();
1203        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1204
1205        cx.update(|window, cx| {
1206            group.update(cx, |group, cx| {
1207                group.set_constraints(TabGroupConstraints::in_split(true), window, cx)
1208            })
1209        });
1210        let alone = cx.update(|_, cx| group.read(cx).context(cx).is_draggable());
1211        cx.update(|window, cx| {
1212            group.update(cx, |group, cx| {
1213                group.set_constraints(TabGroupConstraints::in_split(false), window, cx)
1214            })
1215        });
1216        let beside_others = cx.update(|_, cx| group.read(cx).context(cx).is_draggable());
1217
1218        assert!(!alone);
1219        assert!(beside_others);
1220    }
1221
1222    #[gpui::test]
1223    fn a_panel_from_another_group_lands_as_one_move(cx: &mut TestAppContext) {
1224        let log = log_of();
1225        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1226        let events = record_events(&group, cx);
1227
1228        let drag = DragPanel::new(PanelId::from_u64(99), elsewhere());
1229        cx.update(|_, cx| {
1230            group.update(cx, |group, cx| group.on_drop(&drag, None, true, cx));
1231        });
1232        cx.run_until_parked();
1233
1234        assert_eq!(
1235            *events.borrow(),
1236            vec!["drop panel 99 from 7 into tabs 1 at None activate=true"]
1237        );
1238    }
1239
1240    #[gpui::test]
1241    fn dropping_a_panel_back_onto_its_own_group_is_ignored(cx: &mut TestAppContext) {
1242        let log = log_of();
1243        let (group, panels, cx) = build_group(&log, &["a", "b"], cx);
1244        let events = record_events(&group, cx);
1245
1246        let panel = panel_id(&panels[0], cx);
1247        let drag = DragPanel::new(panel, group_node());
1248        cx.update(|_, cx| {
1249            group.update(cx, |group, cx| group.on_drop(&drag, None, true, cx));
1250        });
1251        cx.run_until_parked();
1252
1253        assert!(events.borrow().is_empty());
1254    }
1255
1256    #[gpui::test]
1257    fn a_hovering_split_turns_a_drop_into_a_split(cx: &mut TestAppContext) {
1258        let log = log_of();
1259        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1260        let events = record_events(&group, cx);
1261
1262        let drag = DragPanel::new(PanelId::from_u64(99), elsewhere());
1263        cx.update(|_, cx| {
1264            group.update(cx, |group, cx| {
1265                group.sync_drop_placeholder(
1266                    content_bounds(),
1267                    Some(Placement::Right),
1268                    drag.drag_session_id(),
1269                    DropPlaceholderBounds::for_placement(content_bounds(), None),
1270                    cx,
1271                );
1272                group.on_drop(&drag, None, true, cx);
1273            });
1274        });
1275        cx.run_until_parked();
1276
1277        assert_eq!(*events.borrow(), vec!["drop panel 99 from 7 split 1 Right"]);
1278    }
1279
1280    /// The tab bar reports the slot a drop landed on, and a slot always means
1281    /// "into these tabs" however the content area had resolved the cursor.
1282    #[gpui::test]
1283    fn a_tab_slot_overrides_the_hovering_split(cx: &mut TestAppContext) {
1284        let log = log_of();
1285        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1286        let events = record_events(&group, cx);
1287
1288        let drag = DragPanel::new(PanelId::from_u64(99), elsewhere());
1289        cx.update(|_, cx| {
1290            group.update(cx, |group, cx| {
1291                group.sync_drop_placeholder(
1292                    content_bounds(),
1293                    Some(Placement::Right),
1294                    drag.drag_session_id(),
1295                    DropPlaceholderBounds::for_placement(content_bounds(), None),
1296                    cx,
1297                );
1298                group.on_drop(&drag, Some(0), true, cx);
1299            });
1300        });
1301        cx.run_until_parked();
1302
1303        assert_eq!(
1304            *events.borrow(),
1305            vec!["drop panel 99 from 7 into tabs 1 at Some(0) activate=true"]
1306        );
1307    }
1308
1309    /// A lone panel splitting out of its own group would empty that group and
1310    /// refill it, which is no move at all.
1311    #[gpui::test]
1312    fn a_lone_panel_cannot_split_out_of_its_own_group(cx: &mut TestAppContext) {
1313        let log = log_of();
1314        let (group, panels, cx) = build_group(&log, &["a"], cx);
1315        let events = record_events(&group, cx);
1316
1317        let drag = DragPanel::new(panel_id(&panels[0], cx), group_node());
1318        cx.update(|_, cx| {
1319            group.update(cx, |group, cx| {
1320                group.sync_drop_placeholder(
1321                    content_bounds(),
1322                    Some(Placement::Right),
1323                    drag.drag_session_id(),
1324                    DropPlaceholderBounds::for_placement(content_bounds(), None),
1325                    cx,
1326                );
1327                group.on_drop(&drag, None, true, cx);
1328            });
1329        });
1330        cx.run_until_parked();
1331
1332        assert!(events.borrow().is_empty());
1333    }
1334
1335    #[gpui::test]
1336    fn the_placeholder_replays_only_when_the_target_moves(cx: &mut TestAppContext) {
1337        let log = log_of();
1338        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1339        let bounds = content_bounds();
1340        let source = DropPlaceholderBounds::new(point(px(10.), px(10.)), size(px(96.), px(30.)));
1341
1342        let sync = |placement: Option<Placement>, cx: &mut VisualTestContext| {
1343            cx.update(|_, cx| {
1344                group.update(cx, |group, cx| {
1345                    group.sync_drop_placeholder(bounds, placement, 42, source, cx);
1346                    group.drop_indicator.unwrap()
1347                })
1348            })
1349        };
1350
1351        let first = sync(Some(Placement::Left), cx);
1352        let again = sync(Some(Placement::Left), cx);
1353        let moved = sync(Some(Placement::Right), cx);
1354
1355        assert_eq!(first.epoch(), 0);
1356        assert_eq!(first.from(), source, "the first run flies in from the drag");
1357        assert_eq!(again.epoch(), 0, "an unchanged target keeps animating");
1358        assert_eq!(again.from(), source);
1359        assert_eq!(moved.epoch(), 1, "a moved target replays from where it was");
1360        assert_eq!(moved.from(), first.to());
1361        assert_eq!(
1362            moved.to(),
1363            DropPlaceholderBounds::for_placement(bounds, Some(Placement::Right))
1364        );
1365    }
1366
1367    /// The indicator is hit state, not a latch: a drag cancelled while
1368    /// hovering must not leave the placeholder painted.
1369    #[gpui::test]
1370    fn the_indicator_is_withheld_when_no_drag_is_in_flight(cx: &mut TestAppContext) {
1371        let log = log_of();
1372        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1373
1374        let (stored, published) = cx.update(|_, cx| {
1375            group.update(cx, |group, cx| {
1376                group.sync_drop_placeholder(
1377                    content_bounds(),
1378                    Some(Placement::Top),
1379                    42,
1380                    DropPlaceholderBounds::for_placement(content_bounds(), None),
1381                    cx,
1382                );
1383                (
1384                    group.drop_indicator.is_some(),
1385                    group.context(cx).drop_indicator().is_some(),
1386                )
1387            })
1388        });
1389
1390        assert!(stored);
1391        assert!(!published);
1392    }
1393
1394    #[gpui::test]
1395    fn a_host_drag_reports_the_group_and_the_edge_it_resolved(cx: &mut TestAppContext) {
1396        let log = log_of();
1397        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1398        let events = record_events(&group, cx);
1399
1400        cx.update(|_, cx| {
1401            group.update(cx, |group, cx| {
1402                group.emit_drag_drop(&AnyDrag::new(7u32), Some(Placement::Bottom), cx)
1403            });
1404        });
1405        cx.run_until_parked();
1406
1407        assert_eq!(*events.borrow(), vec!["item onto 1 at Some(Bottom)"]);
1408    }
1409
1410    #[gpui::test]
1411    fn closing_asks_the_container_rather_than_editing_the_group(cx: &mut TestAppContext) {
1412        let log = log_of();
1413        let (group, panels, cx) = build_group(&log, &["a", "b"], cx);
1414        let events = record_events(&group, cx);
1415        let panel = panel_id(&panels[1], cx);
1416
1417        cx.update(|_, cx| {
1418            group.update(cx, |group, cx| {
1419                group.close_panel(panel, cx);
1420                // A panel that is not a member is not this group's to close.
1421                group.close_panel(PanelId::from_u64(4242), cx);
1422            });
1423        });
1424        cx.run_until_parked();
1425
1426        assert_eq!(*events.borrow(), vec![format!("close {}", panel.as_u64())]);
1427        assert_eq!(
1428            cx.update(|_, cx| group.read(cx).panels().len()),
1429            2,
1430            "the group still holds both panels until the container edits the tree"
1431        );
1432    }
1433
1434    #[gpui::test]
1435    fn zooming_toggles_and_tells_the_displayed_panel(cx: &mut TestAppContext) {
1436        let log = log_of();
1437        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1438        let events = record_events(&group, cx);
1439
1440        cx.update(|window, cx| group.update(cx, |group, cx| group.toggle_zoom(window, cx)));
1441        cx.run_until_parked();
1442        cx.update(|window, cx| group.update(cx, |group, cx| group.toggle_zoom(window, cx)));
1443        cx.run_until_parked();
1444
1445        assert_eq!(*events.borrow(), vec!["zoom in", "zoom out"]);
1446        assert!(!cx.update(|_, cx| group.read(cx).is_zoomed()));
1447        assert_eq!(
1448            drain(&log)
1449                .into_iter()
1450                .filter(|(_, signal)| matches!(signal, PanelSignal::Zoomed(_)))
1451                .collect::<Vec<_>>(),
1452            vec![
1453                ("a", PanelSignal::Zoomed(true)),
1454                ("a", PanelSignal::Zoomed(false))
1455            ],
1456            "the old TabPanel sent this to itself, where the default no-op swallowed it"
1457        );
1458    }
1459
1460    /// A real drag over a locked group must resolve nothing: base installs no
1461    /// drop handling on one, so the placement is never computed and the layout
1462    /// tree is never asked to move anything.
1463    #[gpui::test]
1464    fn a_drag_over_a_locked_group_resolves_nothing(cx: &mut TestAppContext) {
1465        let (group, _calls, cx) = build_skinned_group(&["a", "b"], cx);
1466        cx.update(|window, cx| {
1467            group.update(cx, |group, cx| {
1468                group.set_constraints(
1469                    TabGroupConstraints::in_split(false).dock_locked(true),
1470                    window,
1471                    cx,
1472                )
1473            })
1474        });
1475        cx.update(|window, cx| window.draw(cx).clear(cx));
1476
1477        drag_from_the_tab_into_the_content(cx);
1478
1479        assert!(
1480            cx.update(|_, cx| group.read(cx).drop_indicator.is_none()),
1481            "a locked group installs no drag-move listener, so nothing resolved"
1482        );
1483    }
1484
1485    /// `Dock::new` marks a dock's last group unclosable so the dock cannot be
1486    /// emptied out from under itself.
1487    #[gpui::test]
1488    fn a_group_the_container_sealed_shut_refuses_to_close(cx: &mut TestAppContext) {
1489        let log = log_of();
1490        let (group, panels, cx) = build_group(&log, &["a", "b"], cx);
1491        let events = record_events(&group, cx);
1492        let panel = panel_id(&panels[0], cx);
1493
1494        cx.update(|window, cx| {
1495            group.update(cx, |group, cx| {
1496                group.set_constraints(
1497                    TabGroupConstraints::in_split(false).closable(false),
1498                    window,
1499                    cx,
1500                );
1501                group.close_panel(panel, cx);
1502            })
1503        });
1504        cx.run_until_parked();
1505
1506        assert!(!cx.update(|_, cx| group.read(cx).context(cx).is_closable()));
1507        assert!(events.borrow().is_empty());
1508    }
1509
1510    /// The last visible panel of the only group has nowhere to go, so it is
1511    /// not closable either — closing it would empty the region out from under
1512    /// itself. Placing a sibling beside the group makes the same panel
1513    /// closable again, which is what pins the reason to `alone` rather than to
1514    /// something else the sealed default also forbids.
1515    #[gpui::test]
1516    fn the_only_groups_last_panel_is_not_closable(cx: &mut TestAppContext) {
1517        let log = log_of();
1518        let (group, _panels, cx) = build_group(&log, &["a"], cx);
1519
1520        cx.update(|window, cx| {
1521            group.update(cx, |group, cx| {
1522                group.set_constraints(TabGroupConstraints::in_split(true), window, cx)
1523            })
1524        });
1525        let alone = cx.update(|_, cx| group.read(cx).context(cx).is_closable());
1526
1527        cx.update(|window, cx| {
1528            group.update(cx, |group, cx| {
1529                group.set_constraints(TabGroupConstraints::in_split(false), window, cx)
1530            })
1531        });
1532        let beside_a_sibling = cx.update(|_, cx| group.read(cx).context(cx).is_closable());
1533
1534        assert!(!alone);
1535        assert!(beside_a_sibling);
1536    }
1537
1538    /// Collapsing takes the displayed panel off screen, and the active-state
1539    /// contract counts that as no panel being displayed.
1540    #[gpui::test]
1541    fn collapsing_deactivates_the_displayed_panel_and_expanding_restores_it(
1542        cx: &mut TestAppContext,
1543    ) {
1544        let log = log_of();
1545        let (group, _panels, cx) = build_group(&log, &["a", "b"], cx);
1546        cx.run_until_parked();
1547        assert_eq!(drain_active(&log), vec![("a", true)]);
1548
1549        cx.update(|window, cx| {
1550            group.update(cx, |group, cx| {
1551                group.set_constraints(
1552                    TabGroupConstraints::in_split(false).collapsed(true),
1553                    window,
1554                    cx,
1555                )
1556            })
1557        });
1558        cx.run_until_parked();
1559        assert_eq!(drain_active(&log), vec![("a", false)]);
1560
1561        cx.update(|window, cx| {
1562            group.update(cx, |group, cx| {
1563                group.set_constraints(TabGroupConstraints::in_split(false), window, cx)
1564            })
1565        });
1566        cx.run_until_parked();
1567        assert_eq!(drain_active(&log), vec![("a", true)]);
1568    }
1569
1570    // ---- the renderer seam ----
1571
1572    /// Where `RecordingRenderer` puts its content frame. Deliberately not the
1573    /// window's own origin or size, so a listener on the outer frame would
1574    /// report different bounds.
1575    fn skin_content() -> Bounds<Pixels> {
1576        Bounds {
1577            origin: point(px(100.), px(50.)),
1578            size: size(px(400.), px(300.)),
1579        }
1580    }
1581
1582    /// A skin that records which hooks base calls, and lays its two frames out
1583    /// at known coordinates so a test can tell them apart by hit geometry.
1584    struct RecordingRenderer {
1585        calls: Rc<RefCell<Vec<&'static str>>>,
1586    }
1587
1588    impl RecordingRenderer {
1589        fn saw(&self, call: &'static str) {
1590            self.calls.borrow_mut().push(call);
1591        }
1592    }
1593
1594    impl TabGroupRenderer for RecordingRenderer {
1595        fn frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
1596            self.saw("frame");
1597            div().id("skin-frame").relative().size_full()
1598        }
1599
1600        fn content_frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
1601            self.saw("content_frame");
1602            div()
1603                .id("skin-content")
1604                .absolute()
1605                .left(skin_content().origin.x)
1606                .top(skin_content().origin.y)
1607                .w(skin_content().size.width)
1608                .h(skin_content().size.height)
1609        }
1610
1611        fn render_tab_bar(
1612            &self,
1613            group: &TabGroupContext,
1614            _: &mut Window,
1615            cx: &mut App,
1616        ) -> AnyElement {
1617            self.saw("tab_bar");
1618            div()
1619                .id("skin-tab")
1620                .absolute()
1621                .left(px(0.))
1622                .top(px(0.))
1623                .w(px(80.))
1624                .h(px(24.))
1625                .when_some(group.drag_panel(0, cx), |this, drag| {
1626                    this.on_drag(drag, |drag, offset, _, cx| {
1627                        drag.set_drag_offset(offset);
1628                        cx.new(|_| drag.clone())
1629                    })
1630                })
1631                .into_any_element()
1632        }
1633
1634        fn render_active_panel(
1635            &self,
1636            panel: AnyView,
1637            _: &TabGroupContext,
1638            _: &mut Window,
1639            _: &mut App,
1640        ) -> AnyElement {
1641            self.saw("active_panel");
1642            panel.into_any_element()
1643        }
1644
1645        fn render_empty(
1646            &self,
1647            _: &TabGroupContext,
1648            _: &mut Window,
1649            _: &mut App,
1650        ) -> Option<AnyElement> {
1651            self.saw("empty");
1652            None
1653        }
1654
1655        fn render_drop_indicator(
1656            &self,
1657            _: DropIndicator,
1658            _: &mut Window,
1659            _: &mut App,
1660        ) -> Option<AnyElement> {
1661            self.saw("drop_indicator");
1662            None
1663        }
1664    }
1665
1666    fn build_skinned_group<'a>(
1667        names: &[&'static str],
1668        cx: &'a mut TestAppContext,
1669    ) -> (
1670        Entity<TabGroup>,
1671        Rc<RefCell<Vec<&'static str>>>,
1672        &'a mut VisualTestContext,
1673    ) {
1674        let calls: Rc<RefCell<Vec<&'static str>>> = Rc::default();
1675        let renderer = Rc::new(RecordingRenderer {
1676            calls: calls.clone(),
1677        });
1678        let (group, cx) = cx.add_window_view(|window, cx| {
1679            TabGroup::new(NodeId::from_u64(1), window, cx).with_renderer(renderer)
1680        });
1681
1682        let names = names.to_vec();
1683        let log = log_of();
1684        cx.update(|window, cx| {
1685            let views: Vec<Arc<dyn PanelView>> = names
1686                .iter()
1687                .map(|name| {
1688                    Arc::new(crate::dock::test_support::TestPanel::logging(
1689                        name, &log, cx,
1690                    )) as _
1691                })
1692                .collect();
1693            group.update(cx, |group, cx| {
1694                group.set_constraints(TabGroupConstraints::in_split(false), window, cx);
1695                group.sync_from_tree(views, 0, window, cx);
1696            });
1697        });
1698        cx.run_until_parked();
1699        calls.borrow_mut().clear();
1700
1701        (group, calls, cx)
1702    }
1703
1704    /// Press on the skin's tab, start the drag, and move into the right-hand
1705    /// third of `skin_content()`.
1706    fn drag_from_the_tab_into_the_content(cx: &mut VisualTestContext) {
1707        cx.simulate_mouse_down(
1708            point(px(20.), px(10.)),
1709            MouseButton::Left,
1710            Modifiers::none(),
1711        );
1712        cx.simulate_mouse_move(
1713            point(px(30.), px(14.)),
1714            MouseButton::Left,
1715            Modifiers::none(),
1716        );
1717        cx.simulate_mouse_move(
1718            point(px(450.), px(200.)),
1719            MouseButton::Left,
1720            Modifiers::none(),
1721        );
1722    }
1723
1724    /// The composition contract every later renderer copies: which hooks base
1725    /// calls, and in what order.
1726    #[gpui::test]
1727    fn the_renderer_composes_frame_then_tab_bar_then_content(cx: &mut TestAppContext) {
1728        let (_group, calls, cx) = build_skinned_group(&["a"], cx);
1729
1730        cx.update(|window, cx| window.draw(cx).clear(cx));
1731
1732        assert_eq!(
1733            *calls.borrow(),
1734            vec!["frame", "tab_bar", "content_frame", "active_panel"],
1735            "the tab bar is a sibling of the content, not a child of it"
1736        );
1737    }
1738
1739    /// With nothing to display base asks for the empty element instead of the
1740    /// active panel, and never for both.
1741    #[gpui::test]
1742    fn an_empty_group_asks_the_renderer_for_its_empty_state(cx: &mut TestAppContext) {
1743        let (_group, calls, cx) = build_skinned_group(&[], cx);
1744
1745        cx.update(|window, cx| window.draw(cx).clear(cx));
1746
1747        assert_eq!(
1748            *calls.borrow(),
1749            vec!["frame", "tab_bar", "content_frame", "empty"]
1750        );
1751    }
1752
1753    /// The drop listeners must sit on the content frame, not the outer frame:
1754    /// a drag over the group resolves against the content frame's own bounds,
1755    /// which the skin — not base — decides.
1756    #[gpui::test]
1757    fn the_content_frame_is_what_a_drag_is_measured_against(cx: &mut TestAppContext) {
1758        let (group, calls, cx) = build_skinned_group(&["a", "b"], cx);
1759        cx.update(|window, cx| window.draw(cx).clear(cx));
1760
1761        drag_from_the_tab_into_the_content(cx);
1762
1763        let indicator = cx
1764            .update(|_, cx| group.read(cx).drop_indicator)
1765            .expect("the content frame's drag-move listener ran");
1766
1767        assert_eq!(
1768            indicator.bounds(),
1769            skin_content(),
1770            "measured against the content frame, not the full-size outer frame"
1771        );
1772        assert_eq!(indicator.placement(), Some(Placement::Right));
1773
1774        cx.update(|window, cx| window.draw(cx).clear(cx));
1775        assert!(
1776            calls.borrow().contains(&"drop_indicator"),
1777            "a published indicator is handed to the renderer to draw"
1778        );
1779    }
1780
1781    /// A drop landing on the content frame reports a move the container can
1782    /// apply, and clears the hover state behind it.
1783    #[gpui::test]
1784    fn dropping_on_the_content_frame_reports_the_move(cx: &mut TestAppContext) {
1785        let (group, _calls, cx) = build_skinned_group(&["a", "b"], cx);
1786        let events = record_events(&group, cx);
1787        cx.update(|window, cx| window.draw(cx).clear(cx));
1788
1789        drag_from_the_tab_into_the_content(cx);
1790        cx.simulate_mouse_up(
1791            point(px(450.), px(200.)),
1792            MouseButton::Left,
1793            Modifiers::none(),
1794        );
1795        cx.run_until_parked();
1796
1797        assert_eq!(events.borrow().len(), 1, "got {:?}", events.borrow());
1798        assert!(
1799            events.borrow()[0].ends_with("split 1 Right"),
1800            "got {:?}",
1801            events.borrow()
1802        );
1803        assert!(cx.update(|_, cx| group.read(cx).drop_indicator.is_none()));
1804    }
1805}