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