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