Skip to main content

gpui_base/dock/
tab_group.rs

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