Skip to main content

gpui_kit/navigation/
tabs.rs

1//! A strip of tabs that reports which one was chosen.
2//!
3//! The selected tab is caller-owned. `Tabs` reports the id that was picked and
4//! renders whatever the caller says is current, so a host that refuses a move
5//! keeps the tab that still holds underlined.
6//!
7//! `Tabs` renders the strip only. The body belongs to the caller, which is why
8//! no `Role::TabPanel` node is published here.
9//!
10//! # Document tabs
11//!
12//! A document tab is this same strip carrying two more facts: whether the
13//! thing behind it has changes nobody has written down yet ([`SaveState`]),
14//! and whether it can be put away ([`TabItem::closable`]). It is not a second
15//! component, because everything a document tab does — reporting a selection
16//! it does not apply, refusing a tab, stepping with the arrow keys in reading
17//! order, being dragged somewhere else — is what this strip already does. A
18//! separate `DocumentTabs` would have to reimplement all of it and would then
19//! be a second place for the two of them to disagree about what a tab is.
20//!
21//! Overflow is a menu of the tabs that did not fit rather than a scrolling
22//! strip. See [`Tabs::overflow_after`].
23
24use std::rc::Rc;
25
26use gpui::{
27    App, Entity, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement,
28    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, point,
29    prelude::FluentBuilder, px,
30};
31use gpui_kit_assets::{Icon, icon};
32use gpui_kit_semantics::{NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, ControlMetrics, ControlSize, Space, Theme, TypeScale};
34
35use crate::display::badge::Badge;
36use crate::foundation::direction::{ActiveDirection, DirectionalExt};
37use crate::foundation::stepping::bounded_step;
38use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
39use crate::interaction::dnd::{
40    self, DragItem, DropAxis, DropIntent, DropPosition, MakingWay, RowTarget, SurfaceDrag,
41};
42use crate::motion::{Flipping, flip};
43use crate::overlay::{Menu, MenuItem};
44use crate::strings::{ActiveStrings, StringKey};
45
46type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
47type CloseHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
48type ReorderHandler = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
49type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
50
51/// The width of the dot a tab wears while it has something unsaved. It occurs
52/// once, so it stays next to the component rather than in the token document.
53const MARK_SIZE: f32 = 7.0;
54
55/// Whether what a tab holds has been written down, and what happened when
56/// somebody tried.
57///
58/// The three unclean variants are separate presentations, not one "modified"
59/// flag with a colour. A save that failed silently and then showed a clean tab
60/// would tell the typist their work is safe when it is not, which is the exact
61/// failure this library exists to avoid — so [`SaveState::Failed`] carries the
62/// host's own reason and the tab publishes itself as invalid.
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub enum SaveState {
65    /// Everything in this tab is written down.
66    #[default]
67    Clean,
68    /// There are changes nobody has written down yet.
69    Dirty,
70    /// A save is in flight. Not clean: it has not landed.
71    Saving,
72    /// A save was attempted and did not land, in the host's own words.
73    Failed { reason: SharedString },
74}
75
76impl SaveState {
77    /// The name the semantic tree publishes, so a test tells the three apart
78    /// without reading a colour.
79    pub fn name(&self) -> &'static str {
80        match self {
81            Self::Clean => "clean",
82            Self::Dirty => "dirty",
83            Self::Saving => "saving",
84            Self::Failed { .. } => "save-failed",
85        }
86    }
87
88    pub fn is_clean(&self) -> bool {
89        matches!(self, Self::Clean)
90    }
91
92    /// What a reader is told. A clean tab is told nothing at all.
93    fn wording(&self, cx: &App) -> Option<SharedString> {
94        match self {
95            // The host's words outrank the catalogue's, so a failure states
96            // the reason it was given rather than a generic sentence.
97            Self::Failed { reason } => Some(reason.clone()),
98            Self::Dirty => Some(cx.strings().text(StringKey::TabDirty)),
99            Self::Saving => Some(cx.strings().text(StringKey::TabSaving)),
100            Self::Clean => None,
101        }
102    }
103
104    /// The glyph an overflowed tab carries in the menu, where there is no room
105    /// to draw the mark the strip draws.
106    fn menu_icon(&self) -> Option<Icon> {
107        match self {
108            Self::Clean => None,
109            Self::Dirty => Some(Icon::Pen),
110            Self::Saving => Some(Icon::Refresh),
111            Self::Failed { .. } => Some(Icon::Danger),
112        }
113    }
114}
115
116/// One tab, identified by business identity rather than by position.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct TabItem {
119    pub id: SharedString,
120    pub label: SharedString,
121    pub icon: Option<Icon>,
122    pub badge: Option<SharedString>,
123    pub disabled: bool,
124    pub save_state: SaveState,
125    pub closable: bool,
126}
127
128impl TabItem {
129    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
130        Self {
131            id: id.into(),
132            label: label.into(),
133            icon: None,
134            badge: None,
135            disabled: false,
136            save_state: SaveState::Clean,
137            closable: false,
138        }
139    }
140
141    pub fn icon(mut self, icon: Icon) -> Self {
142        self.icon = Some(icon);
143        self
144    }
145
146    /// A count shown next to the label, such as how many items the tab holds.
147    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
148        self.badge = Some(badge.into());
149        self
150    }
151
152    pub fn disabled(mut self, disabled: bool) -> Self {
153        self.disabled = disabled;
154        self
155    }
156
157    /// Whether what this tab holds has been written down.
158    pub fn save_state(mut self, state: SaveState) -> Self {
159        self.save_state = state;
160        self
161    }
162
163    /// There are changes nobody has written down yet.
164    pub fn dirty(self) -> Self {
165        self.save_state(SaveState::Dirty)
166    }
167
168    /// A save is in flight. Distinct from clean, because it has not landed.
169    pub fn saving(self) -> Self {
170        self.save_state(SaveState::Saving)
171    }
172
173    /// A save was attempted and did not land, in the host's own words.
174    pub fn save_failed(self, reason: impl Into<SharedString>) -> Self {
175        self.save_state(SaveState::Failed {
176            reason: reason.into(),
177        })
178    }
179
180    /// Whether this tab carries a close affordance.
181    ///
182    /// Closing is reported through [`Tabs::on_close`]; a strip with no such
183    /// handler draws no close control however many tabs claim to be closable.
184    pub fn closable(mut self, closable: bool) -> Self {
185        self.closable = closable;
186        self
187    }
188
189    /// The row this tab becomes when it does not fit in the strip.
190    ///
191    /// A hidden tab that is the current one still has to read as the current
192    /// one, and a menu row says so with a checkmark. That takes the glyph
193    /// slot, so the current tab shows no save mark in the menu; every other
194    /// row carries one, and the strip is where a current tab's mark is read.
195    fn menu_row(&self, selected: bool) -> MenuItem {
196        if selected {
197            return MenuItem::check(self.id.clone(), self.label.clone(), true)
198                .disabled(self.disabled);
199        }
200        let mut row =
201            MenuItem::command(self.id.clone(), self.label.clone()).disabled(self.disabled);
202        if let Some(glyph) = self.save_state.menu_icon().or(self.icon) {
203            row = row.icon(glyph);
204        }
205        row
206    }
207}
208
209/// A row of tabs. The strip publishes one [`Role::Tab`] node per tab.
210#[derive(IntoElement)]
211pub struct Tabs {
212    ident: Ident,
213    tabs: Vec<TabItem>,
214    selected: Option<SharedString>,
215    size: ControlSize,
216    disabled: bool,
217    on_select: Option<SelectHandler>,
218    on_close: Option<CloseHandler>,
219    reorderable: bool,
220    accepts: Option<Accepts>,
221    on_reorder: Option<ReorderHandler>,
222    overflow_after: Option<usize>,
223    overflow_menu: Option<Entity<Menu>>,
224}
225
226impl std::fmt::Debug for Tabs {
227    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        formatter
229            .debug_struct("Tabs")
230            .field("ident", &self.ident)
231            .field("tabs", &self.tabs.len())
232            .field("selected", &self.selected)
233            .field("disabled", &self.disabled)
234            .field("has_handler", &self.on_select.is_some())
235            .field("closable", &self.on_close.is_some())
236            .field("overflow_after", &self.overflow_after)
237            .finish()
238    }
239}
240
241impl Tabs {
242    pub fn new(ident: impl Into<Ident>) -> Self {
243        Self {
244            ident: ident.into(),
245            tabs: Vec::new(),
246            selected: None,
247            size: ControlSize::Md,
248            disabled: false,
249            on_select: None,
250            on_close: None,
251            reorderable: false,
252            accepts: None,
253            on_reorder: None,
254            overflow_after: None,
255            overflow_menu: None,
256        }
257    }
258
259    pub fn tab(mut self, tab: TabItem) -> Self {
260        self.tabs.push(tab);
261        self
262    }
263
264    pub fn tabs(mut self, tabs: impl IntoIterator<Item = TabItem>) -> Self {
265        self.tabs.extend(tabs);
266        self
267    }
268
269    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
270        self.selected = Some(id.into());
271        self
272    }
273
274    pub fn on_select(
275        mut self,
276        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
277    ) -> Self {
278        self.on_select = Some(Rc::new(handler));
279        self
280    }
281
282    /// Reports the tab that should be put away. The strip closes nothing.
283    ///
284    /// Only a tab marked [`TabItem::closable`] gets a control, and the control
285    /// is a target of its own: it swallows the click that reaches it, so the
286    /// gesture that means "switch to this tab" cannot land on it by accident.
287    /// A middle click anywhere on a closable tab reports the same thing, which
288    /// is the platform convention wherever a pointer has three buttons.
289    pub fn on_close(
290        mut self,
291        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
292    ) -> Self {
293        self.on_close = Some(Rc::new(handler));
294        self
295    }
296
297    /// Keeps the first `count` tabs in the strip and moves the rest into the
298    /// overflow menu.
299    ///
300    /// The cut is declared rather than measured, for the reason
301    /// [`Toolbar::overflow_after`](crate::layout::Toolbar::overflow_after)
302    /// states: GPUI measures after the element tree exists, so a strip cannot
303    /// find out what fits and then still move a tab somewhere else.
304    ///
305    /// A menu rather than a scrolling strip, because a scroll offset is not
306    /// something a reader can address: a tab scrolled out of view is at a
307    /// position nobody can name, has no bounds, publishes nothing, and a
308    /// horizontal scroll region would also eat the left and right arrows the
309    /// strip uses to step between tabs. A menu keeps every hidden tab named,
310    /// listed, and carrying its own save state. Either way the keyboard
311    /// reaches a hidden tab directly: arrow, home and end step over **every**
312    /// tab the caller declared, hidden or not, and report the one they land
313    /// on.
314    pub fn overflow_after(mut self, count: usize) -> Self {
315        self.overflow_after = Some(count);
316        self
317    }
318
319    /// The menu the overflowed tabs are moved into.
320    ///
321    /// Caller-owned, because whether it is open outlives a frame. The menu
322    /// reports the tab that was taken as [`MenuEvent::Invoked`](crate::overlay::MenuEvent),
323    /// carrying the same id the strip would have reported.
324    pub fn overflow_menu(mut self, menu: Entity<Menu>) -> Self {
325        self.overflow_menu = Some(menu);
326        self
327    }
328
329    /// Where the cut falls, which is nowhere when there is no menu to move
330    /// anything into.
331    fn cut(&self) -> usize {
332        match (self.overflow_after, self.overflow_menu.is_some()) {
333            (Some(cut), true) => cut,
334            _ => usize::MAX,
335        }
336    }
337
338    /// Lets a tab be dragged to another place in the strip.
339    pub fn reorderable(mut self, reorderable: bool) -> Self {
340        self.reorderable = reorderable;
341        self
342    }
343
344    /// Whether this strip takes a payload, and where. Without one, it takes
345    /// its own tabs and nothing else.
346    pub fn accepts(
347        mut self,
348        predicate: impl Fn(&DragItem, &DropPosition) -> bool + 'static,
349    ) -> Self {
350        self.accepts = Some(Rc::new(predicate));
351        self
352    }
353
354    /// Reports where a dropped tab should go. The strip does not move it.
355    pub fn on_reorder(
356        mut self,
357        handler: impl Fn(&DropIntent, &mut Window, &mut App) + 'static,
358    ) -> Self {
359        self.on_reorder = Some(Rc::new(handler));
360        self
361    }
362
363    fn reorder(&self, window: &mut Window, cx: &mut App) -> Option<Reorder> {
364        if self.disabled || !self.reorderable {
365            return None;
366        }
367        let on_drop = self.on_reorder.clone()?;
368        let surface = self.ident.semantic_id();
369        let accepts = self.accepts.clone().unwrap_or_else(|| {
370            let own = surface.clone();
371            Rc::new(move |item: &DragItem, _: &DropPosition| item.source == own)
372        });
373        Some(Reorder {
374            drag: dnd::surface_drag(&surface, window, cx),
375            surface,
376            accepts,
377            on_drop,
378        })
379    }
380
381    /// Whether this tab can be put away right now: the caller said it can,
382    /// the strip has somewhere to report it, and nothing is refused.
383    fn closes(&self, tab: &TabItem) -> bool {
384        tab.closable && !self.disabled && !tab.disabled && self.on_close.is_some()
385    }
386
387    /// The mark a tab wears while what it holds is not written down.
388    ///
389    /// A clean tab draws nothing and publishes nothing, which is what makes
390    /// the mark's presence the whole signal.
391    fn save_mark(
392        &self,
393        tab: &TabItem,
394        ident: &Ident,
395        theme: &Theme,
396        cx: &App,
397    ) -> Option<gpui::AnyElement> {
398        let wording = tab.save_state.wording(cx)?;
399        let (color, filled) = match tab.save_state {
400            SaveState::Dirty => (theme.colors.accent, true),
401            // A save in flight is drawn as an outline of the dirty dot: the
402            // work is still not written down, and the ring says something is
403            // happening to it without claiming it landed.
404            SaveState::Saving => (theme.colors.text_muted, false),
405            SaveState::Failed { .. } => (theme.colors.danger, true),
406            SaveState::Clean => return None,
407        };
408        Some(
409            div()
410                .flex_none()
411                .size(px(MARK_SIZE))
412                .rounded_full()
413                .when(filled, |element| element.bg(color))
414                .when(!filled, |element| {
415                    element.border(px(theme.borders.thick)).border_color(color)
416                })
417                .semantic_in(
418                    cx,
419                    NodeSpec::new(ident.child("save").semantic_id(), Role::Status)
420                        .parent(ident.semantic_id())
421                        .text(wording)
422                        .value(tab.save_state.name())
423                        .busy(matches!(tab.save_state, SaveState::Saving))
424                        .invalid(matches!(tab.save_state, SaveState::Failed { .. })),
425                )
426                .into_any_element(),
427        )
428    }
429
430    /// The control that puts a tab away.
431    ///
432    /// It is a hit target of its own with its own identity, and it stops the
433    /// click travelling, so the gesture that means "switch to this tab" cannot
434    /// land on it by accident.
435    fn close_control(
436        &self,
437        tab: &TabItem,
438        ident: &Ident,
439        theme: &Theme,
440        metrics: ControlMetrics,
441        cx: &mut App,
442    ) -> Option<gpui::AnyElement> {
443        let handler = self.on_close.clone().filter(|_| self.closes(tab))?;
444        let close_ident = ident.child("close");
445        let name = cx
446            .strings()
447            .format(StringKey::TabClose, &[tab.label.as_ref()]);
448        let id = tab.id.clone();
449        let keyed_id = id.clone();
450        let keyed = Rc::clone(&handler);
451
452        Some(
453            div()
454                .id(close_ident.element_id())
455                .flex()
456                .flex_none()
457                .items_center()
458                .justify_center()
459                .size(px(metrics.icon_size + 4.0))
460                .rounded_full()
461                .cursor_pointer()
462                .tab_index(0)
463                .hover(|style| style.bg(theme.colors.hover))
464                .focus_ring(theme)
465                .child(
466                    icon(Icon::Close)
467                        .size(px(metrics.icon_size - 3.0))
468                        .text_color(theme.colors.text_muted),
469                )
470                .on_click(move |_, window, cx| {
471                    cx.stop_propagation();
472                    handler(id.clone(), window, cx);
473                })
474                .on_key_down(move |event, window, cx| {
475                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
476                        cx.stop_propagation();
477                        keyed(keyed_id.clone(), window, cx);
478                    }
479                })
480                .semantic_in(
481                    cx,
482                    NodeSpec::new(close_ident.semantic_id(), Role::Button)
483                        .parent(ident.semantic_id())
484                        .text(name),
485                )
486                .into_any_element(),
487        )
488    }
489
490    #[allow(clippy::too_many_arguments)]
491    fn tab_element(
492        &self,
493        tab: &TabItem,
494        index: usize,
495        theme: &Theme,
496        metrics: ControlMetrics,
497        reorder: Option<&Reorder>,
498        window: &mut Window,
499        cx: &mut App,
500    ) -> gpui::AnyElement {
501        let selected = self.selected.as_ref() == Some(&tab.id);
502        let disabled = self.disabled || tab.disabled;
503        let actionable = !disabled && self.on_select.is_some();
504        let draggable = reorder.filter(|_| !disabled);
505        let drag = draggable.and_then(|reorder| reorder.drag.as_ref());
506        let carried = drag.is_some_and(|drag| drag.carries(&tab.id));
507        let landing = drag.and_then(|drag| drag.indicator_for(&tab.id));
508        let ident = self.ident.child(tab.id.as_ref());
509        let hover_group = ident.child("hover").semantic_id();
510        let color = if disabled {
511            theme.colors.text_faint
512        } else if selected {
513            theme.colors.text
514        } else {
515            theme.colors.text_muted
516        };
517
518        let mut element = div()
519            .id(ident.element_id())
520            .group(hover_group.clone())
521            .flex_none()
522            .column()
523            .child(
524                div()
525                    .row()
526                    .h(px(metrics.height))
527                    .px(px(metrics.padding_x))
528                    .gap(px(metrics.gap))
529                    .children(
530                        tab.icon
531                            .map(|glyph| icon(glyph).size(px(metrics.icon_size)).text_color(color)),
532                    )
533                    .child(
534                        text(theme, TypeScale::Label, tab.label.clone())
535                            .text_size(px(metrics.font_size))
536                            .text_color(color)
537                            .when(actionable, |element| {
538                                element.group_hover(hover_group, |style| {
539                                    style.text_color(theme.colors.text)
540                                })
541                            }),
542                    )
543                    .children(tab.badge.clone().map(|badge| Badge::new(badge).neutral()))
544                    .children(self.save_mark(tab, &ident, theme, cx))
545                    .children(self.close_control(tab, &ident, theme, metrics, cx)),
546            )
547            // The underline is a sibling rather than a border so an unselected
548            // tab reserves the same height and nothing shifts when it is
549            // chosen. The accent bar inside it is one element for the whole
550            // strip, so choosing another tab moves it instead of putting a
551            // second one somewhere else.
552            .child(
553                div()
554                    .relative()
555                    .h(px(theme.borders.thick))
556                    .children(selected.then(|| {
557                        let indicator = flip(self.ident.child("indicator").semantic_id(), cx);
558                        div()
559                            .absolute()
560                            .inset_0()
561                            .bg(theme.colors.accent)
562                            .flip(&indicator, window, cx)
563                    })),
564            )
565            .children(landing.map(|(position, accepted)| {
566                dnd::indicator(&position, accepted, DropAxis::Horizontal, cx)
567            }))
568            .when(disabled, |element| element.opacity(theme.opacity.disabled))
569            .when(carried, |element| element.opacity(theme.opacity.muted))
570            .when(actionable, |element| {
571                element
572                    .cursor_pointer()
573                    .tab_index(0)
574                    .pressable(cx)
575                    .focus_ring(theme)
576            });
577
578        if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
579            let id = tab.id.clone();
580            let click = Rc::clone(&handler);
581            let clicked = id.clone();
582            element = element
583                .on_click(move |_, window, cx| click(clicked.clone(), window, cx))
584                .on_key_down(move |event, window, cx| {
585                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
586                        handler(id.clone(), window, cx);
587                        cx.stop_propagation();
588                    }
589                });
590        }
591
592        // A middle click is the platform's own "put this away" on every
593        // pointer that has three buttons; a pointer that has two never sends
594        // it, so nothing is lost where the convention does not exist. It sits
595        // on mouse-down rather than on click because the middle button has no
596        // click gesture in GPUI.
597        if let (true, Some(handler)) = (self.closes(tab), self.on_close.clone()) {
598            let id = tab.id.clone();
599            element = element.on_mouse_down(
600                MouseButton::Middle,
601                move |_: &MouseDownEvent, window, cx| {
602                    handler(id.clone(), window, cx);
603                    cx.stop_propagation();
604                },
605            );
606        }
607
608        if let Some(reorder) = draggable {
609            let mut item =
610                DragItem::new(reorder.surface.clone(), tab.id.clone(), tab.label.clone());
611            if let Some(glyph) = tab.icon {
612                item = item.icon(glyph);
613            }
614            element = dnd::draggable(element, item);
615            element = dnd::drop_target(
616                element,
617                RowTarget {
618                    surface: reorder.surface.clone(),
619                    id: tab.id.clone(),
620                    index,
621                    allow_into: false,
622                    axis: DropAxis::Horizontal,
623                    accepts: Rc::clone(&reorder.accepts),
624                    on_drop: Rc::clone(&reorder.on_drop),
625                },
626            );
627        }
628
629        let element = element.semantic_in(
630            cx,
631            NodeSpec::new(ident.semantic_id(), Role::Tab)
632                .parent(self.ident.semantic_id())
633                .checked(selected)
634                .disabled(disabled)
635                .text(tab.label.clone())
636                // The state is published by name, so a test tells dirty from
637                // saving from a save that failed without reading a colour.
638                .value(tab.save_state.name())
639                .busy(matches!(tab.save_state, SaveState::Saving))
640                .invalid(matches!(tab.save_state, SaveState::Failed { .. })),
641        );
642
643        match draggable {
644            Some(reorder) => {
645                let shift = reorder
646                    .drag
647                    .as_ref()
648                    .filter(|drag| drag.makes_way(index))
649                    .map_or(px(0.0), |_| dnd::make_way_gap(cx, DropAxis::Horizontal));
650                element
651                    .make_way(ident.semantic_id(), point(shift, px(0.0)), window, cx)
652                    .into_any_element()
653            }
654            None => element.into_any_element(),
655        }
656    }
657}
658
659/// What a tab needs to take part in a reorder.
660#[derive(Clone)]
661struct Reorder {
662    surface: SharedString,
663    drag: Option<SurfaceDrag>,
664    accepts: Accepts,
665    on_drop: ReorderHandler,
666}
667
668impl Disableable for Tabs {
669    /// Refuses the whole strip. A disabled strip installs no handler at all,
670    /// including the keyboard one.
671    fn disabled(mut self, disabled: bool) -> Self {
672        self.disabled = disabled;
673        self
674    }
675}
676
677impl Sizable for Tabs {
678    fn control_size(mut self, size: ControlSize) -> Self {
679        self.size = size;
680        self
681    }
682}
683
684impl RenderOnce for Tabs {
685    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
686        let theme = cx.theme().clone();
687        let metrics = theme.control.get(self.size);
688        let reorder = self.reorder(window, cx);
689
690        let direction = cx.layout_direction();
691        let mut strip = div()
692            .id(self.ident.element_id())
693            .row_reading(direction)
694            .items_end()
695            .flex_wrap()
696            .gap(px(theme.space(Space::Xs)));
697
698        if let (false, Some(handler)) = (self.disabled, self.on_select.clone()) {
699            let tabs = self.tabs.clone();
700            let selected = self.selected.clone();
701            // A strip of tabs runs in reading order, so the arrow that means
702            // "the previous tab" is the one pointing back the way the strip
703            // was laid out, not the one pointing left.
704            strip = strip.on_key_down(move |event, window, cx| {
705                let key = event.keystroke.key.as_str();
706                let next = match direction.arrow_step(key) {
707                    Some(step_by) => step(&tabs, selected.as_ref(), step_by as isize),
708                    None => match key {
709                        "home" => edge(&tabs, -1),
710                        "end" => edge(&tabs, 1),
711                        _ => return,
712                    },
713                };
714                // A move that lands nowhere, or back on the tab that is
715                // already current, is not a choice and is not reported.
716                let Some(next) = next.filter(|next| Some(next) != selected.as_ref()) else {
717                    return;
718                };
719                handler(next, window, cx);
720                cx.stop_propagation();
721            });
722        }
723
724        let cut = self.cut();
725        let mut hidden: Vec<MenuItem> = Vec::new();
726        for (index, tab) in self.tabs.iter().enumerate() {
727            if index >= cut {
728                hidden.push(tab.menu_row(self.selected.as_ref() == Some(&tab.id)));
729                continue;
730            }
731            strip = strip.child(self.tab_element(
732                tab,
733                index,
734                &theme,
735                metrics,
736                reorder.as_ref(),
737                window,
738                cx,
739            ));
740        }
741
742        let hidden_count = hidden.len();
743        let overflow = self
744            .overflow_menu
745            .clone()
746            .filter(|_| hidden_count > 0)
747            .map(|menu| {
748                if menu.read(cx).offered() != hidden.as_slice() {
749                    menu.update(cx, |menu, cx| menu.set_items(hidden, cx));
750                }
751                let overflow_ident = self.ident.child("overflow");
752                div()
753                    .flex()
754                    .flex_none()
755                    .child(menu)
756                    // The trigger says how many tabs moved here, so a snapshot
757                    // shows that they were relocated and not dropped.
758                    .semantic_in(
759                        cx,
760                        NodeSpec::new(overflow_ident.semantic_id(), Role::Group)
761                            .parent(self.ident.semantic_id())
762                            .text(cx.strings().text(StringKey::TabMoreTabs))
763                            .value(hidden_count.to_string()),
764                    )
765            });
766
767        strip.children(overflow).semantic_in(
768            cx,
769            // The strip holds every tab the caller declared, drawn or
770            // overflowed, because the keyboard reaches all of them.
771            NodeSpec::new(self.ident.semantic_id(), Role::List).value(self.tabs.len().to_string()),
772        )
773    }
774}
775
776/// The next tab that can be chosen in `delta`'s direction, skipping refusals.
777///
778/// Movement stops at the ends instead of wrapping, so arrowing past the last
779/// tab reports nothing rather than jumping back to the first.
780fn step(tabs: &[TabItem], selected: Option<&SharedString>, delta: isize) -> Option<SharedString> {
781    let from = selected.and_then(|id| tabs.iter().position(|tab| &tab.id == id));
782    bounded_step(tabs.len(), from, delta, |index| tabs[index].disabled)
783        .map(|index| tabs[index].id.clone())
784}
785
786/// The first tab from the left when `delta` is negative, from the right when
787/// it is positive.
788fn edge(tabs: &[TabItem], delta: isize) -> Option<SharedString> {
789    step(tabs, None, -delta)
790}