Skip to main content

gpui_cn/
tabs.rs

1//! A browser-style tab strip: a bar of tabs whose selected tab merges into
2//! the content below it, with a close control, a new-tab control, and
3//! scroll controls when the tabs overflow.
4//!
5//! The look is measured from the tab reference app. [`TabsState`] owns the
6//! tabs, the selection, and the scroll position; [`Tabs`] renders it.
7//!
8//! ```
9//! use gpui_cn::{Tab, Tabs, TabsState};
10//! use gpui_kit::{Entity, IntoElement};
11//!
12//! fn strip(state: &Entity<TabsState>) -> impl IntoElement {
13//!     Tabs::new("tabs", state)
14//! }
15//!
16//! fn tabs() -> TabsState {
17//!     TabsState::new([Tab::new("home", "Home"), Tab::new("logs", "Logs")])
18//! }
19//! ```
20
21use std::{fmt, rc::Rc, sync::Arc};
22
23use gpui_kit::{
24    AnyElement, App, AppContext as _, Context, DispatchPhase, Div, ElementId, Entity, EventEmitter,
25    HitboxBehavior, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
26    ParentElement as _, Pixels, RenderOnce, ScrollEvent, ScrollHandle, SharedString,
27    StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window,
28    assets::IconName,
29    base::{
30        self, Disableable as _, ElementExt as _, Interpolate as _, Sequence, StyledExt as _,
31        TestSupportExt as _, h_flex, transition,
32    },
33    canvas, div, point,
34    prelude::FluentBuilder as _,
35    px,
36};
37
38use crate::{
39    ActiveTheme as _, Button, ButtonSize, Icon, Input, InputEvent, InputState, MenuAnchor,
40    MenuEntry, MenuState, Theme, ThemeTokens, TooltipHost, menu::MenuPanels,
41    tooltip::TooltipTrigger,
42};
43/// One tab in the strip. The id is the application's key for the tab and
44/// must be unique in the strip; it never comes from a list index.
45#[derive(Clone)]
46pub struct Tab {
47    id: SharedString,
48    label: SharedString,
49    icon: Option<Icon>,
50}
51
52impl Tab {
53    /// A tab with a stable id and a visible label.
54    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
55        Self {
56            id: id.into(),
57            label: label.into(),
58            icon: None,
59        }
60    }
61
62    /// An icon before the label.
63    pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
64        self.icon = Some(icon.into());
65        self
66    }
67
68    /// The application's key for the tab.
69    pub fn id(&self) -> &SharedString {
70        &self.id
71    }
72
73    /// The visible label.
74    pub fn label(&self) -> &SharedString {
75        &self.label
76    }
77
78    /// The icon before the label, if any.
79    pub fn icon(&self) -> Option<&Icon> {
80        self.icon.as_ref()
81    }
82}
83
84impl fmt::Debug for Tab {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("Tab")
87            .field("id", &self.id)
88            .field("label", &self.label)
89            .field("icon", &self.icon.as_ref().map(Icon::source))
90            .finish()
91    }
92}
93
94/// Emitted by [`TabsState`] after it changed.
95#[derive(Clone, Debug, PartialEq, Eq)]
96#[non_exhaustive]
97pub enum TabsEvent {
98    /// The selected tab changed. The payload is the tab id.
99    Selected(SharedString),
100    /// A tab was removed by its close control or by
101    /// [`remove`](TabsState::remove). The payload is its id.
102    Closed(SharedString),
103    /// The new-tab control was activated. The application decides what a
104    /// new tab is and calls [`push`](TabsState::push).
105    AddRequested,
106    /// A tab's label changed, by [`rename`](TabsState::rename) or by an
107    /// inline rename the user committed. The payload is its id.
108    Renamed(SharedString),
109    /// A tab's close control was activated on a strip built with
110    /// [`confirm_close`](Tabs::confirm_close). The tab stays until the
111    /// application calls [`remove`](TabsState::remove). The payload is its
112    /// id.
113    CloseRequested(SharedString),
114}
115
116/// The tabs, which one is selected, and the scroll position of the strip.
117///
118/// Edit it through its methods so the strip observes the change. Every
119/// method leaves the state whole before it emits, so a subscriber reads
120/// the tabs and the selection as they will render.
121pub struct TabsState {
122    tabs: Vec<Tab>,
123    selected: Option<SharedString>,
124    scroll: ScrollHandle,
125    glide: Option<Glide>,
126    /// Whether the selected tab must be brought into view. Set by a
127    /// selection, cleared by the strip once it has asked the scroll for it.
128    reveal: bool,
129    /// Tabs pushed since the last frame settled, which grow into place.
130    entering: Vec<SharedString>,
131    /// Tabs removed but still shrinking out of the strip, at the index
132    /// they held.
133    leaving: Vec<Leaving>,
134    /// The tab whose label is a text field, while the user renames it.
135    renaming: Option<Renaming>,
136}
137
138/// An inline rename under way: the tab and the field its label became.
139struct Renaming {
140    id: SharedString,
141    input: Entity<InputState>,
142    _subscription: Subscription,
143}
144
145/// A removed tab on its way out. It is no longer in [`TabsState::tabs`]
146/// and takes no input; the strip draws it until its width reaches zero.
147#[derive(Clone, Debug)]
148struct Leaving {
149    tab: Tab,
150    index: usize,
151}
152
153/// A scroll the controls asked for, which the strip plays as motion from
154/// the offset it had to the one it wants. The serial tells one glide from
155/// the next, so each starts where the strip is and not where the last
156/// glide ended.
157#[derive(Clone, Copy, Debug, PartialEq)]
158struct Glide {
159    serial: usize,
160    from: Pixels,
161    to: Pixels,
162}
163
164impl EventEmitter<TabsEvent> for TabsState {}
165
166impl TabsState {
167    /// The tabs, with the first one selected. Ids must be unique.
168    pub fn new(tabs: impl IntoIterator<Item = Tab>) -> Self {
169        let tabs: Vec<Tab> = tabs.into_iter().collect();
170        debug_assert!(unique_ids(&tabs), "tab ids must be unique");
171        let selected = tabs.first().map(|tab| tab.id.clone());
172        Self {
173            tabs,
174            selected,
175            scroll: ScrollHandle::new(),
176            glide: None,
177            reveal: false,
178            entering: Vec::new(),
179            leaving: Vec::new(),
180            renaming: None,
181        }
182    }
183
184    /// The tabs, in strip order.
185    pub fn tabs(&self) -> &[Tab] {
186        &self.tabs
187    }
188
189    /// The id of the selected tab.
190    pub fn selected(&self) -> Option<&SharedString> {
191        self.selected.as_ref()
192    }
193
194    fn index_of(&self, id: &SharedString) -> Option<usize> {
195        self.tabs.iter().position(|tab| &tab.id == id)
196    }
197
198    fn selected_index(&self) -> Option<usize> {
199        self.selected.as_ref().and_then(|id| self.index_of(id))
200    }
201
202    /// Selects the tab with `id` and scrolls it into view. A no-op for an
203    /// unknown id or the tab already selected.
204    pub fn select(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
205        let id = id.into();
206        let Some(index) = self.index_of(&id) else {
207            return;
208        };
209        if self.selected.as_ref() == Some(&id) {
210            return;
211        }
212        self.select_index(index);
213        cx.emit(TabsEvent::Selected(id));
214        cx.notify();
215    }
216
217    /// Selects the tab `step` places from the selected one, wrapping at
218    /// the ends: `-1` for the previous tab, `1` for the next.
219    pub fn select_neighbor(&mut self, step: isize, cx: &mut Context<Self>) {
220        let len = self.tabs.len();
221        if len == 0 {
222            return;
223        }
224        let current = self.selected_index().unwrap_or(0) as isize;
225        let next = (current + step).rem_euclid(len as isize) as usize;
226        let id = self.tabs[next].id.clone();
227        self.select(id, cx);
228    }
229
230    /// Selects the first tab, or the last one when `last` is set.
231    pub fn select_edge(&mut self, last: bool, cx: &mut Context<Self>) {
232        let Some(tab) = (if last {
233            self.tabs.last()
234        } else {
235            self.tabs.first()
236        }) else {
237            return;
238        };
239        let id = tab.id.clone();
240        self.select(id, cx);
241    }
242
243    fn select_index(&mut self, index: usize) {
244        self.selected = Some(self.tabs[index].id.clone());
245        self.reveal = true;
246    }
247
248    fn revealed(&mut self, cx: &mut Context<Self>) {
249        if self.reveal {
250            self.reveal = false;
251            cx.notify();
252        }
253    }
254
255    /// Appends a tab and selects it. Its id must be new to the strip.
256    pub fn push(&mut self, tab: Tab, cx: &mut Context<Self>) {
257        debug_assert!(self.index_of(&tab.id).is_none(), "tab ids must be unique");
258        let id = tab.id.clone();
259        self.leaving.retain(|leaving| leaving.tab.id != id);
260        self.tabs.push(tab);
261        self.entering.push(id.clone());
262        self.select_index(self.tabs.len() - 1);
263        cx.emit(TabsEvent::Selected(id));
264        cx.notify();
265    }
266
267    /// Inserts a tab at `index`, clamped to the end, without selecting it,
268    /// so a host that keeps its own order can put a tab where it belongs.
269    /// Its id must be new to the strip.
270    pub fn insert(&mut self, index: usize, tab: Tab, cx: &mut Context<Self>) {
271        debug_assert!(self.index_of(&tab.id).is_none(), "tab ids must be unique");
272        let index = index.min(self.tabs.len());
273        let id = tab.id.clone();
274        self.leaving.retain(|leaving| leaving.tab.id != id);
275        self.tabs.insert(index, tab);
276        self.entering.push(id);
277        cx.notify();
278    }
279
280    /// Removes the tab with `id`. If it was selected, the tab after it is
281    /// selected, or the one before it when it was last. Emits `Closed`,
282    /// then `Selected` when the selection moved.
283    pub fn remove(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
284        let id = id.into();
285        let Some(index) = self.index_of(&id) else {
286            return;
287        };
288        let was_selected = self.selected.as_ref() == Some(&id);
289        let tab = self.tabs.remove(index);
290        self.entering.retain(|entering| entering != &id);
291        self.leaving.push(Leaving { tab, index });
292        let next = if was_selected {
293            self.selected = None;
294            next_selection(index, self.tabs.len())
295        } else {
296            None
297        };
298        if let Some(next) = next {
299            self.select_index(next);
300        }
301        cx.emit(TabsEvent::Closed(id));
302        if let Some(next) = next {
303            cx.emit(TabsEvent::Selected(self.tabs[next].id.clone()));
304        }
305        cx.notify();
306    }
307
308    /// Sets or clears the icon of the tab with `id`, such as to mark a
309    /// state the tab is in. A no-op for an unknown id.
310    pub fn set_icon(
311        &mut self,
312        id: impl Into<SharedString>,
313        icon: Option<impl Into<Icon>>,
314        cx: &mut Context<Self>,
315    ) {
316        let id = id.into();
317        if let Some(index) = self.index_of(&id) {
318            self.tabs[index].icon = icon.map(Into::into);
319            cx.notify();
320        }
321    }
322
323    /// Asks the application for a new tab.
324    pub fn request_add(&mut self, cx: &mut Context<Self>) {
325        cx.emit(TabsEvent::AddRequested);
326    }
327
328    /// Sets the label of the tab with `id` and emits `Renamed`. A no-op
329    /// for an unknown id or an unchanged label.
330    pub fn rename(
331        &mut self,
332        id: impl Into<SharedString>,
333        label: impl Into<SharedString>,
334        cx: &mut Context<Self>,
335    ) {
336        let id = id.into();
337        let label = label.into();
338        let Some(index) = self.index_of(&id) else {
339            return;
340        };
341        if self.tabs[index].label == label {
342            return;
343        }
344        self.tabs[index].label = label;
345        cx.emit(TabsEvent::Renamed(id));
346        cx.notify();
347    }
348
349    /// Turns the label of the tab with `id` into a text field holding the
350    /// label, selected and focused. Enter or leaving the field commits a
351    /// non-empty name; Escape cancels. A strip that is
352    /// [`renamable`](Tabs::renamable) starts one on a double click.
353    pub fn start_rename(
354        &mut self,
355        id: impl Into<SharedString>,
356        window: &mut Window,
357        cx: &mut Context<Self>,
358    ) {
359        let id = id.into();
360        let Some(index) = self.index_of(&id) else {
361            return;
362        };
363        let label = self.tabs[index].label.clone();
364        let input = cx.new(|cx| InputState::new(window, cx).default_value(label));
365        input.update(cx, |input, cx| {
366            input.focus(window, cx);
367            input.select_all(window, cx);
368        });
369        let subscription = cx.subscribe_in(&input, window, |this, _, event, _, cx| {
370            if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) {
371                this.commit_rename(cx);
372            }
373        });
374        self.renaming = Some(Renaming {
375            id,
376            input,
377            _subscription: subscription,
378        });
379        cx.notify();
380    }
381
382    /// Ends an inline rename and keeps the typed name, unless it is blank.
383    pub fn commit_rename(&mut self, cx: &mut Context<Self>) {
384        let Some(renaming) = self.renaming.take() else {
385            return;
386        };
387        let value = renaming.input.read(cx).value();
388        let name = value.trim();
389        if !name.is_empty() {
390            self.rename(renaming.id, name.to_owned(), cx);
391        }
392        cx.notify();
393    }
394
395    /// Ends an inline rename and keeps the old label.
396    pub fn cancel_rename(&mut self, cx: &mut Context<Self>) {
397        if self.renaming.take().is_some() {
398            cx.notify();
399        }
400    }
401
402    /// The id of the tab being renamed inline, if any.
403    pub fn renaming(&self) -> Option<&SharedString> {
404        self.renaming.as_ref().map(|renaming| &renaming.id)
405    }
406
407    /// The strip reports that the tab's enter or exit motion ended: an
408    /// entered tab is an ordinary tab from now on, and a left tab is gone.
409    fn settle(&mut self, id: &SharedString, cx: &mut Context<Self>) {
410        let before = (self.entering.len(), self.leaving.len());
411        self.entering.retain(|entering| entering != id);
412        self.leaving.retain(|leaving| &leaving.tab.id != id);
413        if before != (self.entering.len(), self.leaving.len()) {
414            cx.notify();
415        }
416    }
417
418    /// Glides the strip by `delta`, clamped to the content. A positive
419    /// delta shows tabs further right.
420    fn scroll_by(&mut self, delta: Pixels, cx: &mut Context<Self>) {
421        let from = self.scroll.offset().x;
422        self.glide_to(from, from - delta, cx);
423    }
424
425    /// Glides the strip by a mouse wheel step. Steps that arrive while a
426    /// glide is under way stack on its end, so a spinning wheel keeps one
427    /// smooth run.
428    fn wheel_by(&mut self, step: Pixels, cx: &mut Context<Self>) {
429        let from = self.scroll.offset().x;
430        let base = match self.glide {
431            Some(glide)
432                if (glide.from..=glide.to).contains(&from)
433                    || (glide.to..=glide.from).contains(&from) =>
434            {
435                glide.to
436            }
437            _ => from,
438        };
439        self.glide_to(from, base + step, cx);
440    }
441
442    /// Moves the strip by a trackpad or touch step at once, clamped to
443    /// the content, and ends any glide.
444    fn pan_by(&mut self, step: Pixels, cx: &mut Context<Self>) {
445        let now = self.scroll.offset();
446        let max = self.scroll.max_offset().x;
447        let to = (now.x + step).clamp(-max, px(0.));
448        self.glide = None;
449        if to != now.x {
450            self.scroll.set_offset(point(to, now.y));
451            cx.notify();
452        }
453    }
454
455    fn glide_to(&mut self, from: Pixels, to: Pixels, cx: &mut Context<Self>) {
456        let max = self.scroll.max_offset().x;
457        let to = to.clamp(-max, px(0.));
458        if to != from {
459            let serial = self.glide.map_or(0, |glide| glide.serial + 1);
460            self.glide = Some(Glide { serial, from, to });
461            cx.notify();
462        }
463    }
464}
465
466fn unique_ids(tabs: &[Tab]) -> bool {
467    tabs.iter()
468        .enumerate()
469        .all(|(index, tab)| !tabs[..index].iter().any(|other| other.id == tab.id))
470}
471
472/// The index to select after the tab at `removed` left a strip that now
473/// holds `len` tabs: the tab that took its place, else the one before it.
474fn next_selection(removed: usize, len: usize) -> Option<usize> {
475    if len == 0 {
476        None
477    } else if removed < len {
478        Some(removed)
479    } else {
480        Some(len - 1)
481    }
482}
483
484/// Whether the tab at `index` paints a separator at its right edge: it is
485/// not selected, not followed by the selected tab, and not the last tab.
486fn separator_after(index: usize, selected_index: Option<usize>, len: usize) -> bool {
487    if index + 1 >= len {
488        return false;
489    }
490    match selected_index {
491        Some(selected) => index != selected && index + 1 != selected,
492        None => true,
493    }
494}
495
496/// A browser-style tab strip on `gpui_base::Tabs` and `gpui_base::Tab`.
497///
498/// Base owns the tab list role and each tab's role, selected state, and
499/// pointer activation. This type owns the look: the bar, the selected
500/// tab's surface with its shoulders, the hover surface, the separators,
501/// and the close, new-tab, and scroll controls. The strip is one tab stop:
502/// with it focused, Left and Right select a neighbor, wrapping at the
503/// ends, and the selected tab shows the focus ring.
504///
505/// A click selects a tab. The close control removes it. The new-tab control
506/// emits [`TabsEvent::AddRequested`], unless the strip hands it to the
507/// application with [`add_trigger`](Tabs::add_trigger). When the tabs overflow the strip,
508/// scroll controls appear at its start.
509///
510/// The strip and the content below share the window surface. A hairline
511/// runs under the strip, and the selected tab is an outline in the
512/// hairline's color that opens into it, with a shoulder at each bottom
513/// corner. A hovered tab fills the same shape.
514///
515/// The strip fills its parent, so give it a sized bar. A tab item is as
516/// tall as the strip, with its surface starting an inset below the top, so
517/// the shoulders always meet the content below. In a [`TitleBar`] clear
518/// the bar's padding (`pl_0()`, `pr_0()`, `pt_0()`) so the strip and its
519/// hairline span the whole bar; the surface inset already holds the tabs
520/// off the top, and the strip keeps its own room at its edges and around
521/// its controls, on the spacing scale.
522///
523/// [`TitleBar`]: crate::TitleBar
524#[derive(IntoElement)]
525pub struct Tabs {
526    id: ElementId,
527    state: Entity<TabsState>,
528    style: StyleRefinement,
529    closable: bool,
530    confirm_close: bool,
531    addable: bool,
532    add_trigger: Option<AddTrigger>,
533    leading: Pixels,
534    renamable: bool,
535    context_menu: Option<(Entity<MenuState>, TabMenu)>,
536}
537
538type AddTrigger = Box<dyn FnOnce(Button) -> AnyElement>;
539
540/// Builds the rows of a tab's context menu, given the tab's id.
541type TabMenu = Rc<dyn Fn(&SharedString, &mut Window, &mut App) -> Vec<MenuEntry>>;
542
543impl Tabs {
544    /// A strip for `state`, with close and new-tab controls.
545    pub fn new(id: impl Into<ElementId>, state: &Entity<TabsState>) -> Self {
546        Self {
547            id: id.into(),
548            state: state.clone(),
549            style: StyleRefinement::default(),
550            closable: true,
551            confirm_close: false,
552            addable: true,
553            add_trigger: None,
554            leading: px(0.),
555            renamable: false,
556            context_menu: None,
557        }
558    }
559
560    /// Whether a double click on a tab turns its label into a text field
561    /// to rename it, as [`TabsState::start_rename`] does. Off by default.
562    pub fn renamable(mut self, renamable: bool) -> Self {
563        self.renamable = renamable;
564        self
565    }
566
567    /// Opens a menu at the pointer on a right click on a tab, with the rows
568    /// `items` builds for that tab's id, as a
569    /// [`ContextMenu`](crate::ContextMenu) does. A tab with no rows opens
570    /// nothing.
571    pub fn context_menu(
572        mut self,
573        state: &Entity<MenuState>,
574        items: impl Fn(&SharedString, &mut Window, &mut App) -> Vec<MenuEntry> + 'static,
575    ) -> Self {
576        self.context_menu = Some((state.clone(), Rc::new(items)));
577        self
578    }
579
580    /// Room at the strip's start, under controls that float over it such
581    /// as a sidebar trigger; the hairline runs under the room and the
582    /// tabs and the scroll controls start after it. The default is none.
583    pub fn with_leading(mut self, leading: impl Into<Pixels>) -> Self {
584        self.leading = leading.into();
585        self
586    }
587
588    /// Whether each tab shows a close control. On by default.
589    pub fn closable(mut self, closable: bool) -> Self {
590        self.closable = closable;
591        self
592    }
593
594    /// Whether a tab's close control asks the application first: it emits
595    /// [`TabsEvent::CloseRequested`] and leaves the tab, so the
596    /// application can confirm and then [`remove`](TabsState::remove) it.
597    /// Off by default.
598    pub fn confirm_close(mut self, confirm: bool) -> Self {
599        self.confirm_close = confirm;
600        self
601    }
602
603    /// Whether the strip ends with a new-tab control. On by default.
604    pub fn addable(mut self, addable: bool) -> Self {
605        self.addable = addable;
606        self
607    }
608
609    /// Hands the new-tab control to `wrap` in place of emitting
610    /// [`TabsEvent::AddRequested`], such as to make it the trigger of a
611    /// [`Popover`](crate::Popover) of what to add. `wrap` gets the control
612    /// as the strip draws it, with no click handler.
613    pub fn add_trigger<E: IntoElement>(mut self, wrap: impl FnOnce(Button) -> E + 'static) -> Self {
614        self.add_trigger = Some(Box::new(move |button| wrap(button).into_any_element()));
615        self
616    }
617}
618
619impl Styled for Tabs {
620    fn style(&mut self) -> &mut StyleRefinement {
621        &mut self.style
622    }
623}
624
625/// Everything the render needs from the theme, read in one borrow.
626struct Look {
627    touch: bool,
628    bar: Hsla,
629    outline: Hsla,
630    hover: Hsla,
631    foreground: Hsla,
632    muted_foreground: Hsla,
633    separator: Hsla,
634    ring: Hsla,
635    surface_inset: Pixels,
636    width: Pixels,
637    min_width: Pixels,
638    shoulder: Pixels,
639    radius: Pixels,
640    gutter: Pixels,
641    separator_inset: Pixels,
642    icon: Pixels,
643    gap: Pixels,
644    control: Pixels,
645    close: Pixels,
646    text_size: Pixels,
647    line_height: Pixels,
648    /// Room at the strip's edges and around its controls: the `sm` step.
649    edge: Pixels,
650    /// The gap between the two scroll controls: the `xs` step.
651    control_gap: Pixels,
652}
653
654impl Look {
655    fn of(theme: &ThemeTokens) -> Self {
656        let metrics = &theme.metrics;
657        Self {
658            touch: theme.touch,
659            bar: theme.background(),
660            outline: theme.border(),
661            hover: theme.muted(),
662            foreground: theme.foreground(),
663            muted_foreground: theme.muted_foreground(),
664            separator: theme.tab_separator,
665            ring: theme.ring(),
666            surface_inset: metrics.tab_surface_inset,
667            width: metrics.tab_width,
668            min_width: metrics.tab_min_width,
669            shoulder: metrics.tab_shoulder,
670            radius: metrics.tab_radius,
671            gutter: metrics.tab_separator_gutter,
672            separator_inset: metrics.tab_separator_inset,
673            icon: metrics.tab_icon,
674            gap: metrics.tab_content_gap,
675            control: metrics.tab_control,
676            close: metrics.tab_close,
677            text_size: theme.text_control.size,
678            line_height: theme.text_control.line_height,
679            edge: theme.base.spacing.sm,
680            control_gap: theme.base.spacing.xs,
681        }
682    }
683}
684
685/// One place in the strip: a live tab at its index, or a tab on its way
686/// out.
687struct Slot<'a> {
688    tab: &'a Tab,
689    live: Option<usize>,
690    entering: bool,
691}
692
693/// What the last layout said about the strip, read from the scroll handle.
694/// The render draws with it, and the prepaint asks for another frame when
695/// the new layout disagrees.
696#[derive(Clone, Copy, PartialEq)]
697struct Overflow {
698    overflowing: bool,
699    at_start: bool,
700    at_end: bool,
701}
702
703impl Overflow {
704    fn of(scroll: &ScrollHandle) -> Self {
705        let max = scroll.max_offset().x;
706        let offset = scroll.offset().x;
707        Self {
708            overflowing: max > px(0.),
709            at_start: offset >= px(0.),
710            at_end: offset <= -max,
711        }
712    }
713}
714
715/// A control slot at the strip's start or end. It is as tall as a tab item
716/// and starts below the surface inset, so its glyph centers on the tab
717/// labels.
718fn slot(look: &Look, control: impl IntoElement) -> impl IntoElement {
719    h_flex()
720        .w(look.control)
721        .h_full()
722        .pt(look.surface_inset)
723        .flex_none()
724        .justify_center()
725        .border_b_1()
726        .border_color(look.outline)
727        .child(control)
728}
729
730/// Empty room in the strip, `width` wide, with its piece of the hairline.
731fn room(look: &Look, width: Pixels) -> Div {
732    div()
733        .flex_none()
734        .h_full()
735        .w(width)
736        .border_b_1()
737        .border_color(look.outline)
738}
739
740/// The hairline under the strip: every part of the strip but the selected
741/// tab draws its own piece of it at the bottom, and this fills the room
742/// after the last control.
743fn hairline(look: &Look) -> impl IntoElement {
744    room(look, look.edge).flex_1().min_w(look.edge)
745}
746
747impl RenderOnce for Tabs {
748    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
749        let look = Look::of(cx.theme());
750        let strip: Arc<ElementId> = Arc::new(self.id.clone());
751        let child = |name: &'static str| ElementId::NamedChild(strip.clone(), name.into());
752        let state = self.state;
753        let (tabs, selected_index, scroll, glide, reveal, entering, leaving, renaming) = {
754            let state = state.read(cx);
755            (
756                state.tabs.clone(),
757                state.selected_index(),
758                state.scroll.clone(),
759                state.glide,
760                state.reveal,
761                state.entering.clone(),
762                state.leaving.clone(),
763                state
764                    .renaming
765                    .as_ref()
766                    .map(|renaming| (renaming.id.clone(), renaming.input.clone())),
767            )
768        };
769        let renamable = self.renamable;
770        let context_menu = self.context_menu.clone();
771        let len = tabs.len();
772        let (motion, fast) = {
773            let motion = &Theme::global(cx).motion;
774            (motion.glide_transition(), motion.fast_transition())
775        };
776        if let Some(glide) = glide {
777            // Keyed by serial, so each glide starts from the offset the
778            // strip had; once landed, the offset is left to the wheel.
779            let key = (child("glide"), SharedString::from(glide.serial.to_string()));
780            let sample = Sequence::new(key, glide.from)
781                .with_step(glide.to, motion.clone())
782                .sample(window, cx);
783            let landed = window.use_keyed_state(child("landed"), cx, |_, _| None::<usize>);
784            if !sample.is_finished() || *landed.read(cx) != Some(glide.serial) {
785                scroll.set_offset(point(*sample.value(), scroll.offset().y));
786                if sample.is_finished() {
787                    landed.update(cx, |landed, _| *landed = Some(glide.serial));
788                }
789            }
790        }
791        let overflow = Overflow::of(&scroll);
792        let has_overlay = TooltipHost::overlay(window, cx).is_some();
793
794        let focus = window
795            .use_keyed_state(child("focus"), cx, |_, cx| cx.focus_handle())
796            .read(cx)
797            .clone();
798        let focus_visible = focus.is_focused(window) && window.last_input_was_keyboard();
799
800        let mut slots: Vec<Slot> = tabs
801            .iter()
802            .enumerate()
803            .map(|(index, tab)| Slot {
804                tab,
805                live: Some(index),
806                entering: entering.contains(&tab.id),
807            })
808            .collect();
809        for leaving in &leaving {
810            let at = leaving.index.min(slots.len());
811            slots.insert(
812                at,
813                Slot {
814                    tab: &leaving.tab,
815                    live: None,
816                    entering: false,
817                },
818            );
819        }
820        // Among the viewport's children: after the lead-in and any tab
821        // leaving before it.
822        let selected_child = slots
823            .iter()
824            .position(|slot| slot.live == selected_index && slot.live.is_some())
825            .map(|position| position + 1);
826        if reveal {
827            if let Some(child) = selected_child {
828                scroll.scroll_to_item(child);
829            }
830            let revealed = state.clone();
831            cx.defer(move |cx| revealed.update(cx, |state, cx| state.revealed(cx)));
832        }
833        let settle_state = state.clone();
834        let items = slots.into_iter().map(|slot| {
835            let tab = slot.tab;
836            let index = slot.live.unwrap_or(0);
837            let live = slot.live.is_some();
838            let selected = live && selected_index == Some(index);
839            let tab_id = Arc::new(ElementId::NamedChild(strip.clone(), tab.id.clone()));
840            // Settling is deferred so the render never writes the entity
841            // it reads.
842            let width = if slot.entering || !live {
843                let (from, to) = if live {
844                    (px(0.), look.width)
845                } else {
846                    (look.width, px(0.))
847                };
848                let sample = Sequence::new((tab_id.as_ref().clone(), "width"), from)
849                    .with_step(to, motion.clone())
850                    .sample(window, cx);
851                if sample.is_finished() {
852                    let settle_state = settle_state.clone();
853                    let id = tab.id.clone();
854                    cx.defer(move |cx| settle_state.update(cx, |state, cx| state.settle(&id, cx)));
855                }
856                Some(*sample.value())
857            } else {
858                None
859            };
860            let part = |name: &'static str| ElementId::NamedChild(tab_id.clone(), name.into());
861            let group = tab.id.clone();
862            let separator = live && separator_after(index, selected_index, len);
863            let gutter = if separator { look.gutter } else { px(0.) };
864            // A shoulder is a circle two shoulders wide, clipped to the
865            // shoulder's square at the corner: its ring is the outline's
866            // curve, its fill carves the hover fill.
867            let hovered = window
868                .use_keyed_state(part("hovered"), cx, |_, _| false)
869                .clone();
870            let hovers = live && !selected && !look.touch && *hovered.read(cx);
871            let selection = transition(
872                part("selection"),
873                f32::from(selected),
874                fast.clone(),
875                window,
876                cx,
877            );
878            let hover = transition(part("hover"), f32::from(hovers), fast.clone(), window, cx);
879            let text = look
880                .muted_foreground
881                .interpolate(&look.foreground, selection);
882            let outline_color = if selected && focus_visible {
883                look.ring
884            } else {
885                look.outline
886            }
887            .opacity(selection);
888            let hairline_color = look.outline.opacity(1. - selection);
889            let hover_color = look.hover.opacity(hover);
890            let shoulder = |side_left: bool| {
891                div()
892                    .absolute()
893                    .bottom_0()
894                    .size(look.shoulder)
895                    .overflow_hidden()
896                    .bg(hover_color)
897                    .map(|this| {
898                        if side_left {
899                            this.left_0()
900                        } else {
901                            this.right(gutter)
902                        }
903                    })
904                    .child(
905                        div()
906                            .absolute()
907                            .size(look.shoulder * 2.)
908                            .rounded_full()
909                            .top(-look.shoulder)
910                            .bg(look.bar)
911                            .border_1()
912                            .border_color(outline_color)
913                            .map(|this| {
914                                if side_left {
915                                    this.left(-look.shoulder)
916                                } else {
917                                    this.right(-look.shoulder)
918                                }
919                            }),
920                    )
921            };
922            let label_id = part("label");
923            let tooltip = TooltipTrigger::text(label_id.clone(), None, tab.label.clone());
924            let select_state = state.clone();
925            let select_id = tab.id.clone();
926            let close_state = state.clone();
927            let close_id = tab.id.clone();
928            let confirm_close = self.confirm_close;
929            let editing = renaming
930                .as_ref()
931                .filter(|(id, _)| live && id == &tab.id)
932                .map(|(_, input)| input.clone());
933            let is_editing = editing.is_some();
934            let menu_id = tab.id.clone();
935            let menu = context_menu.clone();
936            let label = match editing {
937                Some(input) => {
938                    let cancel_state = state.clone();
939                    div()
940                        .flex_1()
941                        .min_w_0()
942                        // Escape cancels before the field sees it, which
943                        // would pass it on anyway.
944                        .capture_key_down(move |event: &KeyDownEvent, _, cx| {
945                            if event.keystroke.key == "escape" {
946                                cx.stop_propagation();
947                                cancel_state.update(cx, |state, cx| state.cancel_rename(cx));
948                            }
949                        })
950                        .child(
951                            Input::new(&input)
952                                .id(part("rename"))
953                                .accessibility_label("Tab name")
954                                .h(look.close)
955                                .px(look.control_gap)
956                                .text_size(look.text_size),
957                        )
958                        .into_any_element()
959                }
960                None => div()
961                    .flex_1()
962                    .min_w_0()
963                    .truncate()
964                    .text_size(look.text_size)
965                    .line_height(look.line_height)
966                    .child(tab.label.clone())
967                    .into_any_element(),
968            };
969            base::Tab::new(tab_id.as_ref().clone())
970                .selected(selected)
971                .set_position(index + 1, len)
972                .accessibility_label(tab.label.clone())
973                .group(group.clone())
974                .relative()
975                .flex_shrink(1.)
976                .h_full()
977                .map(|this| match width {
978                    Some(width) => this
979                        .w(width)
980                        .min_w(width.min(look.min_width))
981                        .overflow_hidden(),
982                    None => this.w(look.width).min_w(look.min_width),
983                })
984                .border_b_1()
985                .border_color(hairline_color)
986                .when(live, |this| {
987                    this.on_hover(move |is_hovered, _, cx| {
988                        hovered.update(cx, |hovered, cx| {
989                            if *hovered != *is_hovered {
990                                *hovered = *is_hovered;
991                                cx.notify();
992                            }
993                        })
994                    })
995                })
996                .when(live, |this| {
997                    this.on_click(move |event, window, cx| {
998                        if is_editing {
999                            return;
1000                        }
1001                        select_state.update(cx, |state, cx| {
1002                            state.select(select_id.clone(), cx);
1003                            if renamable && event.click_count() >= 2 {
1004                                state.start_rename(select_id.clone(), window, cx);
1005                            }
1006                        })
1007                    })
1008                })
1009                .when_some(menu.filter(|_| live), |this, (menu, items)| {
1010                    this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
1011                        cx.stop_propagation();
1012                        let entries = items(&menu_id, window, cx);
1013                        if entries.is_empty() {
1014                            return;
1015                        }
1016                        let anchor = MenuAnchor::Point(event.position);
1017                        menu.update(cx, |menu, cx| menu.open(entries, anchor, window, cx));
1018                    })
1019                })
1020                .child(
1021                    div()
1022                        .absolute()
1023                        .top(look.surface_inset)
1024                        .bottom_0()
1025                        .left_0()
1026                        .right_0()
1027                        .child(shoulder(true))
1028                        .child(shoulder(false))
1029                        // The arc's ring sits one pixel inside its circle,
1030                        // so the sides sit one pixel in to meet it.
1031                        .child(
1032                            div()
1033                                .absolute()
1034                                .top_0()
1035                                .bottom(look.shoulder)
1036                                .left(look.shoulder - px(1.))
1037                                .right(look.shoulder + gutter - px(1.))
1038                                .rounded_t(look.radius)
1039                                .border_1()
1040                                .border_b_0()
1041                                .border_color(outline_color),
1042                        )
1043                        .child(
1044                            h_flex()
1045                                .id(label_id)
1046                                .test_support()
1047                                .absolute()
1048                                .top_0()
1049                                .bottom_0()
1050                                .left(look.shoulder)
1051                                .right(look.shoulder + gutter)
1052                                .min_w_0()
1053                                .rounded_t(look.radius)
1054                                .overflow_hidden()
1055                                .bg(hover_color)
1056                                .px_2()
1057                                .gap(look.gap)
1058                                .text_color(text)
1059                                .map(|this| {
1060                                    if has_overlay {
1061                                        this.on_prepaint(tooltip.track_bounds())
1062                                            .on_hover(move |hovered, window, cx| {
1063                                                tooltip.hovered(*hovered, window, cx)
1064                                            })
1065                                            .on_mouse_down(MouseButton::Left, |_, window, cx| {
1066                                                TooltipTrigger::pressed(window, cx)
1067                                            })
1068                                    } else {
1069                                        this.tooltip(tooltip.native())
1070                                    }
1071                                })
1072                                .when_some(tab.icon.clone(), |this, icon| {
1073                                    this.child(icon.size(look.icon).flex_none())
1074                                })
1075                                .child(label)
1076                                .when(self.closable && live && !is_editing, |this| {
1077                                    this.child(
1078                                        h_flex()
1079                                            .size(look.close)
1080                                            .flex_none()
1081                                            .justify_center()
1082                                            .when(!selected && !look.touch, |this| {
1083                                                this.invisible()
1084                                                    .group_hover(group, |style| style.visible())
1085                                            })
1086                                            .child(
1087                                                Button::new(part("close"))
1088                                                    .ghost()
1089                                                    .size(ButtonSize::Xs)
1090                                                    .icon(IconName::Close)
1091                                                    .rounded_full()
1092                                                    .accessibility_label("Close tab")
1093                                                    .tooltip("Close tab")
1094                                                    .tab_stop(false)
1095                                                    .on_click(move |_, _, cx| {
1096                                                        cx.stop_propagation();
1097                                                        close_state.update(cx, |state, cx| {
1098                                                            if confirm_close {
1099                                                                cx.emit(TabsEvent::CloseRequested(
1100                                                                    close_id.clone(),
1101                                                                ));
1102                                                            } else {
1103                                                                state.remove(close_id.clone(), cx);
1104                                                            }
1105                                                        })
1106                                                    }),
1107                                            ),
1108                                    )
1109                                }),
1110                        ),
1111                )
1112                .when(separator, |this| {
1113                    this.child(
1114                        div()
1115                            .absolute()
1116                            .top(look.separator_inset)
1117                            .bottom(look.separator_inset)
1118                            .right((look.gutter - px(1.)) / 2.)
1119                            .w(px(1.))
1120                            .bg(look.separator),
1121                    )
1122                })
1123        });
1124
1125        let prepaint_scroll = scroll.clone();
1126        let wheel_scroll = scroll.clone();
1127        let wheel_state = state.clone();
1128        let viewport = div()
1129            .on_children_prepainted(move |_, window, _| {
1130                if Overflow::of(&prepaint_scroll) != overflow {
1131                    // The controls that appear next frame shift the tabs,
1132                    // so the selected tab is revealed again after them.
1133                    if let Some(child) = selected_child {
1134                        prepaint_scroll.scroll_to_item(child);
1135                    }
1136                    window.request_animation_frame();
1137                }
1138            })
1139            .id(child("scroll"))
1140            .test_support()
1141            .flex()
1142            .items_end()
1143            .h_full()
1144            .min_w_0()
1145            .overflow_x_scroll()
1146            .track_scroll(&scroll)
1147            // A child, not padding: GPUI counts a scroll box's padding into
1148            // its content twice, which keeps an empty strip scrollable.
1149            .child(room(&look, look.edge))
1150            .children(items);
1151
1152        let back_state = state.clone();
1153        let forward_state = state.clone();
1154        let add_state = state.clone();
1155        let key_state = state;
1156
1157        div()
1158            .id(self.id)
1159            .test_support()
1160            .w_full()
1161            .h_full()
1162            .min_w_0()
1163            .bg(look.bar)
1164            .track_focus(&focus)
1165            .tab_index(0)
1166            .on_key_down(move |event: &KeyDownEvent, _, cx| {
1167                // While a label is a text field, the arrows move its caret.
1168                if event.keystroke.modifiers.modified() || key_state.read(cx).renaming.is_some() {
1169                    return;
1170                }
1171                match event.keystroke.key.as_str() {
1172                    "left" => key_state.update(cx, |state, cx| state.select_neighbor(-1, cx)),
1173                    "right" => key_state.update(cx, |state, cx| state.select_neighbor(1, cx)),
1174                    "home" => key_state.update(cx, |state, cx| state.select_edge(false, cx)),
1175                    "end" => key_state.update(cx, |state, cx| state.select_edge(true, cx)),
1176                    _ => return,
1177                }
1178                cx.stop_propagation();
1179            })
1180            .relative()
1181            .refine_style(&self.style)
1182            // A scroll area around the strip takes the wheel in the capture
1183            // phase, from the outside in, so the strip takes its steps
1184            // there too while it overflows, and its stop keeps them from
1185            // the page. A mouse line glides; a trackpad step lands at once.
1186            .child(
1187                canvas(
1188                    |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
1189                    move |_, hitbox, window, _| {
1190                        window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
1191                            if phase != DispatchPhase::Capture
1192                                || !hitbox.should_handle_scroll(window)
1193                                || wheel_scroll.max_offset().x <= px(0.)
1194                            {
1195                                return;
1196                            }
1197                            cx.stop_propagation();
1198                            let delta = event.delta.pixel_delta(window.line_height());
1199                            let step = if delta.x != px(0.) { delta.x } else { delta.y };
1200                            if step == px(0.) {
1201                                return;
1202                            }
1203                            wheel_state.update(cx, |state, cx| {
1204                                if event.delta.precise() {
1205                                    state.pan_by(step, cx);
1206                                } else {
1207                                    state.wheel_by(step, cx);
1208                                }
1209                            });
1210                        });
1211                    },
1212                )
1213                .absolute()
1214                .inset_0(),
1215            )
1216            .child(
1217                base::Tabs::new(child("list"))
1218                    .flex()
1219                    .items_end()
1220                    .size_full()
1221                    .when(self.leading > px(0.), |this| {
1222                        this.child(room(&look, self.leading))
1223                    })
1224                    .when(overflow.overflowing, |this| {
1225                        this.child(room(&look, look.edge))
1226                            .child(slot(
1227                                &look,
1228                                Button::new(child("back"))
1229                                    .ghost()
1230                                    .size(ButtonSize::Sm)
1231                                    .icon(IconName::ChevronLeft)
1232                                    .accessibility_label("Scroll tabs left")
1233                                    .tooltip("Scroll tabs left")
1234                                    .tab_stop(false)
1235                                    .disabled(overflow.at_start)
1236                                    .on_click(move |_, _, cx| {
1237                                        back_state.update(cx, |state, cx| {
1238                                            state.scroll_by(-look.width, cx)
1239                                        })
1240                                    }),
1241                            ))
1242                            .child(room(&look, look.control_gap))
1243                            .child(slot(
1244                                &look,
1245                                Button::new(child("forward"))
1246                                    .ghost()
1247                                    .size(ButtonSize::Sm)
1248                                    .icon(IconName::ChevronRight)
1249                                    .accessibility_label("Scroll tabs right")
1250                                    .tooltip("Scroll tabs right")
1251                                    .tab_stop(false)
1252                                    .disabled(overflow.at_end)
1253                                    .on_click(move |_, _, cx| {
1254                                        forward_state
1255                                            .update(cx, |state, cx| state.scroll_by(look.width, cx))
1256                                    }),
1257                            ))
1258                            .child(room(&look, look.edge))
1259                    })
1260                    .child(viewport)
1261                    .when(self.addable, |this| {
1262                        let button = Button::new(child("add"))
1263                            .ghost()
1264                            .size(ButtonSize::Sm)
1265                            .icon(IconName::Plus)
1266                            .accessibility_label("New tab")
1267                            .tooltip("New tab");
1268                        let control = match self.add_trigger {
1269                            Some(wrap) => wrap(button),
1270                            None => button
1271                                .on_click(move |_, _, cx| {
1272                                    add_state.update(cx, |state, cx| state.request_add(cx))
1273                                })
1274                                .into_any_element(),
1275                        };
1276                        this.child(room(&look, look.edge))
1277                            .child(slot(&look, control))
1278                    })
1279                    .child(hairline(&look)),
1280            )
1281            .when_some(self.context_menu, |this, (menu, _)| {
1282                this.child(MenuPanels::new(child("menu"), &menu))
1283            })
1284    }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290    use gpui_kit::TestAppContext;
1291
1292    fn ids(state: &TabsState) -> Vec<&str> {
1293        state.tabs.iter().map(|tab| tab.id.as_ref()).collect()
1294    }
1295
1296    #[test]
1297    fn removing_the_selected_tab_selects_the_next_or_the_previous() {
1298        assert_eq!(next_selection(0, 2), Some(0), "the tab that took its place");
1299        assert_eq!(next_selection(1, 2), Some(1));
1300        assert_eq!(
1301            next_selection(2, 2),
1302            Some(1),
1303            "the last tab: the one before"
1304        );
1305        assert_eq!(next_selection(0, 0), None, "nothing left");
1306    }
1307
1308    #[test]
1309    fn a_separator_follows_a_tab_away_from_the_selection_and_the_end() {
1310        // Five tabs, the third selected: | a | b   c   d | e
1311        assert!(separator_after(0, Some(2), 5));
1312        assert!(!separator_after(1, Some(2), 5), "before the selected tab");
1313        assert!(!separator_after(2, Some(2), 5), "the selected tab");
1314        assert!(separator_after(3, Some(2), 5));
1315        assert!(!separator_after(4, Some(2), 5), "the last tab");
1316        assert!(separator_after(0, None, 2), "no selection");
1317        assert!(!separator_after(0, None, 1));
1318    }
1319
1320    #[gpui_kit::test]
1321    fn the_edges_are_selectable(cx: &mut TestAppContext) {
1322        let state = cx
1323            .new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B"), Tab::new("c", "C")]));
1324        state.update(cx, |state, cx| state.select_edge(true, cx));
1325        assert_eq!(
1326            state.read_with(cx, |state, _| state.selected().cloned()),
1327            Some("c".into())
1328        );
1329        state.update(cx, |state, cx| state.select_edge(false, cx));
1330        assert_eq!(
1331            state.read_with(cx, |state, _| state.selected().cloned()),
1332            Some("a".into())
1333        );
1334    }
1335
1336    #[gpui_kit::test]
1337    fn inserting_puts_a_tab_in_place_without_selecting_it(cx: &mut TestAppContext) {
1338        let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("c", "C")]));
1339        state.update(cx, |state, cx| {
1340            state.insert(1, Tab::new("b", "B"), cx);
1341            state.insert(99, Tab::new("d", "D"), cx);
1342        });
1343        state.read_with(cx, |state, _| {
1344            assert_eq!(ids(state), ["a", "b", "c", "d"]);
1345            assert_eq!(state.selected().map(AsRef::as_ref), Some("a"));
1346        });
1347    }
1348
1349    #[gpui_kit::test]
1350    fn the_state_selects_pushes_and_removes(cx: &mut TestAppContext) {
1351        let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B")]));
1352        let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1353        cx.update({
1354            let events = events.clone();
1355            let state = state.clone();
1356            move |cx| {
1357                cx.subscribe(&state, move |_, event: &TabsEvent, _| {
1358                    events.borrow_mut().push(event.clone())
1359                })
1360                .detach();
1361            }
1362        });
1363        assert_eq!(
1364            state.read_with(cx, |state, _| state.selected().cloned()),
1365            Some("a".into())
1366        );
1367        state.update(cx, |state, cx| {
1368            state.select("b", cx);
1369            state.select("b", cx);
1370            state.select("missing", cx);
1371            state.push(Tab::new("c", "C"), cx);
1372            state.remove("c", cx);
1373            state.remove("a", cx);
1374            state.request_add(cx);
1375            assert_eq!(ids(state), ["b"]);
1376            assert_eq!(state.selected(), Some(&"b".into()));
1377            state.remove("b", cx);
1378            assert!(state.selected().is_none());
1379        });
1380        cx.run_until_parked();
1381        assert_eq!(
1382            *events.borrow(),
1383            [
1384                TabsEvent::Selected("b".into()),
1385                TabsEvent::Selected("c".into()),
1386                TabsEvent::Closed("c".into()),
1387                TabsEvent::Selected("b".into()),
1388                TabsEvent::Closed("a".into()),
1389                TabsEvent::AddRequested,
1390                TabsEvent::Closed("b".into()),
1391            ]
1392        );
1393    }
1394
1395    #[gpui_kit::test]
1396    fn a_neighbor_step_wraps_at_both_ends(cx: &mut TestAppContext) {
1397        let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B")]));
1398        state.update(cx, |state, cx| {
1399            state.select_neighbor(1, cx);
1400            assert_eq!(state.selected(), Some(&"b".into()));
1401            state.select_neighbor(1, cx);
1402            assert_eq!(state.selected(), Some(&"a".into()), "wraps");
1403            state.select_neighbor(-1, cx);
1404            assert_eq!(state.selected(), Some(&"b".into()), "wraps back");
1405        });
1406        let empty = cx.new(|_| TabsState::new([]));
1407        empty.update(cx, |state, cx| state.select_neighbor(1, cx));
1408    }
1409
1410    #[test]
1411    fn a_tab_reads_back_and_debugs_without_its_icon_bytes() {
1412        let tab = Tab::new("t", "Terminal").with_icon(IconName::SquareTerminal);
1413        assert_eq!(tab.id(), &SharedString::from("t"));
1414        assert_eq!(tab.label(), &SharedString::from("Terminal"));
1415        assert!(tab.icon().is_some());
1416        assert!(format!("{tab:?}").contains("square-terminal"));
1417    }
1418}