Skip to main content

ui/
tabs.rs

1//! Tabs — an ordered strip of open things, one of them in front.
2//!
3//! Not [`toggle_group`](crate::widgets::Controls::toggle_group), which picks a
4//! value out of a fixed set, and not
5//! [`tab_bar`](crate::widgets::Layout::tab_bar), which switches between
6//! sections of one page. A tab here has identity: it arrives, it can be closed,
7//! it can be dragged past its neighbour, and the strip outlives any particular
8//! membership.
9//!
10//! [`Strip`] is the order and the activation, and imports no gpui — closing,
11//! cycling and reordering are `Vec` arithmetic, testable without a window. The
12//! paint is [`bar`], [`tab`] and [`close`], with [`drop_mark`] for a drop
13//! target and [`Reorder`] for a tab carried along its strip.
14//!
15//! What a tab *holds* never enters this module. `Id` is the caller's key, and
16//! the body it opens is the caller's match on that key.
17//!
18//! ```ignore
19//! ui::tabs::bar("panel-tabs").children(self.strip.tabs().iter().map(|id| {
20//!     let key = SharedString::from(format!("panel-{id}"));
21//!     let state = match self.strip.active() == Some(id) {
22//!         true => tabs::State::Focused,
23//!         false => tabs::State::Resting,
24//!     };
25//!     tabs::tab(&theme, key.clone(), self.label(id), state)
26//!         .on_click(cx.listener(move |view, _, _, cx| view.show(id, cx)))
27//!         .child(
28//!             tabs::close(&theme, key, tabs::Close::OnHover)
29//!                 .on_click(cx.listener(move |view, _, _, cx| view.close(id, cx))),
30//!         )
31//! }))
32//! ```
33
34use std::cmp::Ordering;
35
36use gpui::{
37    AnyElement, BoxShadow, Div, ElementId, Hsla, SharedString, Stateful, StyleRefinement, div,
38    point, prelude::*, px,
39};
40
41use icons::Icon;
42use theme::{TextStyle, Theme, Typeset};
43
44use crate::widgets::{self, Buttons as _};
45
46mod bar;
47mod reorder;
48
49pub use bar::TabBar;
50
51pub use reorder::{Bar, Move, OutsideDrop, Reorder};
52
53/// An ordered set of tabs, one of them active.
54///
55/// `Id` is whatever names a tab to its owner — a counter, a path, a layout
56/// member. Identity is `PartialEq`, so an id that compares equal to one already
57/// in the strip is the same tab.
58///
59/// A strip with tabs in it always has one in front: [`Self::active`] is `None`
60/// only while [`Self::is_empty`].
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct Strip<Id> {
63    order: Vec<Id>,
64    active: Option<Id>,
65}
66
67impl<Id> Default for Strip<Id> {
68    fn default() -> Self {
69        Self {
70            order: Vec::new(),
71            active: None,
72        }
73    }
74}
75
76impl<Id: Clone + PartialEq> FromIterator<Id> for Strip<Id> {
77    /// The first of the run is the one in front.
78    fn from_iter<T: IntoIterator<Item = Id>>(iter: T) -> Self {
79        let order: Vec<Id> = iter.into_iter().collect();
80        let active = order.first().cloned();
81        Self { order, active }
82    }
83}
84
85impl<Id: Clone + PartialEq> Strip<Id> {
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// The tabs, left to right.
91    pub fn tabs(&self) -> &[Id] {
92        &self.order
93    }
94
95    pub fn active(&self) -> Option<&Id> {
96        self.active.as_ref()
97    }
98
99    pub fn len(&self) -> usize {
100        self.order.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.order.is_empty()
105    }
106
107    pub fn contains(&self, id: &Id) -> bool {
108        self.order.contains(id)
109    }
110
111    pub fn index_of(&self, id: &Id) -> Option<usize> {
112        self.order.iter().position(|held| held == id)
113    }
114
115    /// Bring `id` to the front, adding it at the end of the strip if it is not
116    /// already there. An id that *is* there keeps its place.
117    pub fn open(&mut self, id: Id) {
118        if !self.contains(&id) {
119            self.order.push(id.clone());
120        }
121        self.active = Some(id);
122    }
123
124    /// Bring an existing tab to the front. `false`, and nothing moves, when it
125    /// is not in the strip.
126    pub fn activate(&mut self, id: &Id) -> bool {
127        let held = self.contains(id);
128        if held {
129            self.active = Some(id.clone());
130        }
131        held
132    }
133
134    /// Take a tab out. `false` when it was not in the strip.
135    ///
136    /// Closing the tab in front hands the front to its right-hand neighbour,
137    /// or to the new last tab when it had none. Closing any other tab leaves
138    /// the front where it is.
139    pub fn close(&mut self, id: &Id) -> bool {
140        let Some(at) = self.index_of(id) else {
141            return false;
142        };
143        self.order.remove(at);
144        if self.active.as_ref() == Some(id) {
145            self.active = self.order.get(at).or_else(|| self.order.last()).cloned();
146        }
147        true
148    }
149
150    /// Step the front `step` tabs along, wrapping at both ends. Whether the
151    /// front changed: an empty strip, a lone tab and a whole lap do not move.
152    pub fn cycle(&mut self, step: isize) -> bool {
153        if self.order.is_empty() {
154            return false;
155        }
156        let len = self.order.len() as isize;
157        let at = self
158            .active
159            .as_ref()
160            .and_then(|id| self.index_of(id))
161            .unwrap_or(0) as isize;
162        let next = self
163            .order
164            .get((at + step).rem_euclid(len) as usize)
165            .cloned();
166        if next == self.active {
167            return false;
168        }
169        self.active = next;
170        true
171    }
172
173    /// Move the tab at `from` so that it sits at `to`. Out-of-range ends are
174    /// ignored rather than clamped: a drag that left the strip did not mean the
175    /// last slot.
176    ///
177    /// The front is held by identity, so reordering never changes which tab is
178    /// in front.
179    pub fn reorder(&mut self, from: usize, to: usize) {
180        if from == to || from >= self.order.len() || to >= self.order.len() {
181            return;
182        }
183        let moved = self.order.remove(from);
184        self.order.insert(to, moved);
185    }
186
187    /// Carry `id` `travel` pixels along the strip from its slot, positive to
188    /// the right, passing every neighbour whose centre its own centre crosses.
189    ///
190    /// Returns the tabs passed, in the order passed, and the travel left over
191    /// past the new slot. `width` measures a tab; a tab it cannot measure is
192    /// not passed, and neither is anything beyond it.
193    pub fn carry(
194        &mut self,
195        id: &Id,
196        travel: f32,
197        width: impl Fn(&Id) -> Option<f32>,
198    ) -> (Vec<Id>, f32) {
199        let mut passed = Vec::new();
200        let (Some(mut at), Some(own)) = (self.index_of(id), width(id)) else {
201            return (passed, travel);
202        };
203        let mut travel = travel;
204        loop {
205            let next = match travel.partial_cmp(&0.0) {
206                Some(Ordering::Greater) => at + 1,
207                Some(Ordering::Less) if at > 0 => at - 1,
208                _ => break,
209            };
210            let Some(neighbour) = self.order.get(next).cloned() else {
211                break;
212            };
213            let Some(theirs) = width(&neighbour) else {
214                break;
215            };
216            if travel.abs() <= (own + theirs) / 2.0 + GAP {
217                break;
218            }
219            travel -= travel.signum() * (theirs + GAP);
220            self.order.swap(at, next);
221            passed.push(neighbour);
222            at = next;
223        }
224        (passed, travel)
225    }
226}
227
228/// What a tab shows.
229#[derive(Default)]
230pub struct Label {
231    text: SharedString,
232    lead: Option<Lead>,
233    badge: Option<SharedString>,
234    mark: Option<Icon>,
235}
236
237/// What stands before a tab's text.
238enum Lead {
239    Icon(Icon),
240    Drawn(AnyElement),
241}
242
243impl Label {
244    pub fn new(text: impl Into<SharedString>) -> Self {
245        Self {
246            text: text.into(),
247            ..Default::default()
248        }
249    }
250
251    /// A glyph before the text — what kind of thing the tab is on.
252    pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
253        self.lead = Some(Lead::Icon(icon.into()));
254        self
255    }
256
257    /// A drawn element before the text, in place of [`Self::with_icon`]'s
258    /// glyph: an animated mark, a status. Centred in the icon's box, and free
259    /// to paint past it.
260    pub fn with_lead(mut self, lead: impl IntoElement) -> Self {
261        self.lead = Some(Lead::Drawn(lead.into_any_element()));
262        self
263    }
264
265    /// A quiet trailing note: a line number, a reference, a count. It does not
266    /// truncate, so keep it to a few characters.
267    pub fn with_badge(mut self, badge: impl Into<SharedString>) -> Self {
268        self.badge = Some(badge.into());
269        self
270    }
271
272    /// A mark beside the label — unsaved work, a running job, something
273    /// unread.
274    ///
275    /// Painted at [`MARK_SIZE`] in the tab's own tone, outside the truncating
276    /// label so a long name cannot hide it. Lucide's round glyphs are outlines;
277    /// [`Icon::solid`] fills one.
278    pub fn mark(mut self, mark: impl Into<Icon>) -> Self {
279        self.mark = Some(mark.into());
280        self
281    }
282}
283
284/// How a tab reads.
285///
286/// `Front` and `Focused` are separate because a window can hold several strips:
287/// a background pane's own front tab still has to say what is under it, while
288/// only one tab in the window has the keyboard.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub enum State {
291    /// Behind another tab of its strip.
292    Resting,
293    /// In front of its strip, in a pane without the keyboard.
294    Front,
295    /// The tab holding the keyboard. One per window.
296    Focused,
297}
298
299impl State {
300    /// The label's ink.
301    pub fn tint(self, theme: &Theme) -> Hsla {
302        match self {
303            Self::Resting => theme.text_muted,
304            Self::Front | Self::Focused => theme.text,
305        }
306    }
307}
308
309/// What a reorderable strip's tabs stand on — see [`Bar::on_track`]. A track
310/// and its tabs are capsules, a [`Track::Flat`]'s rounded as a control is; the
311/// tabs share its width equally down to
312/// [`TRACK_MIN_WIDTH`], their labels centred, and a line parts two resting
313/// neighbours.
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum Track {
316    /// The segmented control's track, its border drawn.
317    Bordered,
318    /// The same wash with no border.
319    Bare,
320    /// No wash and no border.
321    Flat,
322}
323
324/// When a tab's `×` is on show.
325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
326pub enum Close {
327    Always,
328    OnHover,
329}
330
331/// The edge of a tab a drop lands on.
332#[derive(Clone, Copy, Debug, PartialEq, Eq)]
333pub enum Side {
334    Leading,
335    Trailing,
336}
337
338impl Side {
339    /// Where a tab moved from `from` to `to` lands on the tab whose place it
340    /// takes: its trailing edge moving right, its leading edge moving left.
341    /// `None` when it stays put.
342    pub fn of(from: usize, to: usize) -> Option<Self> {
343        match from.cmp(&to) {
344            Ordering::Less => Some(Self::Trailing),
345            Ordering::Greater => Some(Self::Leading),
346            Ordering::Equal => None,
347        }
348    }
349}
350
351/// A [`tab`]'s drop mark, for its `drag_over` style: a 1px accent line on
352/// `side`, in the gap beside the tab. A shadow, so the tab's layout does not
353/// move.
354pub fn drop_mark(style: StyleRefinement, side: Side, theme: &Theme) -> StyleRefinement {
355    let x = match side {
356        Side::Leading => px(-1.0),
357        Side::Trailing => px(1.0),
358    };
359    style.shadow(vec![BoxShadow {
360        color: theme.accent.opacity(0.6),
361        offset: point(x, px(0.0)),
362        blur_radius: px(0.0),
363        spread_radius: px(0.0),
364        inset: false,
365    }])
366}
367
368/// How wide one tab grows before its label truncates.
369pub const MAX_WIDTH: f32 = 180.0;
370
371/// The narrowest a tab on a [`Track`] gets before its strip scrolls.
372pub const TRACK_MIN_WIDTH: f32 = 120.0;
373
374/// How tall a [`Track`] stands. Its tabs fill it inside its border and
375/// [`TRACK_PAD`].
376pub const TRACK_HEIGHT: f32 = 30.0;
377
378/// The padding between a [`Track`]'s border and its tabs.
379const TRACK_PAD: f32 = 1.0;
380
381/// The box [`Label::mark`] paints in. Lucide's `circle-small` inks 14 of its 24
382/// units, which puts a solid one at 6px across.
383pub const MARK_SIZE: f32 = 10.0;
384
385/// Gap between tabs.
386const GAP: f32 = 2.0;
387/// Inset at a tab's ends, and the gap between what it holds.
388pub const TAB_PAD: f32 = 8.0;
389/// The box a tab's icon, or what stands in its place, is laid in.
390const ICON_SIZE: f32 = 14.0;
391
392/// The strip. Tabs go in it; a `+`, a `···` and anything else on the row are
393/// the caller's, outside this.
394///
395/// It scrolls sideways once the tabs no longer fit, by wheel and trackpad; it
396/// draws no scrollbar. Without [`TabBar::track_scroll`] it keeps its offset
397/// across renders under `id`.
398/// `min_w_0` is what allows
399/// that — a flex child's `min-width: auto` refuses to shrink below its content,
400/// so without it the strip grows past its row instead of scrolling.
401pub fn bar(id: impl Into<ElementId>) -> TabBar {
402    TabBar::new(id.into())
403}
404
405/// One tab, up to the `×`: pass the same `key` to [`close`] and chain the
406/// result on as a child.
407///
408/// `key` names both the element and the hover group [`Close::OnHover`] reads,
409/// so the two are derived from one string rather than written twice.
410///
411/// Every tab but a [`State::Focused`] one takes its own `hover`, and gpui panics
412/// on a second one: reach for [`Close::OnHover`]'s group, or a `group_hover` of
413/// your own, rather than chaining `.hover(..)` onto what this returns.
414pub fn tab(
415    theme: &Theme,
416    key: impl Into<SharedString>,
417    label: Label,
418    state: State,
419) -> Stateful<Div> {
420    let key = key.into();
421    let group = group_of(&key);
422    let tint = state.tint(theme);
423    let wash = theme.element_hover;
424    chip(theme, state)
425        .id(ElementId::from(SharedString::from(format!("tab-{key}"))))
426        .group(group)
427        // Sized to its label, not to the bar: a tab stretched across the strip
428        // reads as a field rather than a label.
429        .flex_none()
430        .max_w(px(MAX_WIDTH))
431        .cursor_pointer()
432        .when(state != State::Focused, |el| {
433            el.hover(move |el| el.bg(wash))
434        })
435        .children(label.lead.map(|lead| {
436            match lead {
437                Lead::Icon(icon) => crate::icons::icon(icon)
438                    .size(px(ICON_SIZE))
439                    .flex_none()
440                    .text_color(theme.text_muted)
441                    .into_any_element(),
442                Lead::Drawn(drawn) => div()
443                    .size(px(ICON_SIZE))
444                    .flex_none()
445                    .flex()
446                    .items_center()
447                    .justify_center()
448                    .child(drawn)
449                    .into_any_element(),
450            }
451        }))
452        .child(div().min_w_0().truncate().child(label.text))
453        .children(label.mark.map(|mark| {
454            crate::icons::icon(mark)
455                .size(px(MARK_SIZE))
456                .flex_none()
457                .text_color(tint)
458        }))
459        .children(label.badge.map(|badge| {
460            div()
461                .flex_none()
462                .text_style(TextStyle::Caption)
463                .text_color(theme.text_faint)
464                .child(badge)
465        }))
466}
467
468/// A tab's frame without its label: its height, inset, corners and ring
469/// slot, the label's ink for `state`, and the selected wash and ring while
470/// [`State::Focused`]. What [`tab`] is drawn on, for a label that is not a
471/// [`Label`].
472pub fn chip(theme: &Theme, state: State) -> Div {
473    div()
474        .flex()
475        .flex_row()
476        .items_center()
477        .gap(px(6.0))
478        .h(px(Theme::BUTTON_HEIGHT))
479        .px(px(TAB_PAD))
480        .rounded(px(Theme::control_radius()))
481        // The slot `focus::focusable` fills, kept whether or not it is filled:
482        // gpui sizes border-box, so a border that arrived with the ring would
483        // shift the label by a pixel.
484        .border_1()
485        .border_color(widgets::RING_SLOT)
486        .text_style(TextStyle::Callout)
487        .text_color(state.tint(theme))
488        .when(state == State::Focused, |el| {
489            el.bg(theme.card_selected_bg())
490                .shadow(theme::glass_selected_shadows())
491        })
492}
493
494/// A tab's `×`, for the `key` its [`tab`] was built with. The click is the
495/// caller's. It stops mouse-down propagation, so a press on it neither clicks
496/// nor picks up the tab under it.
497pub fn close(theme: &Theme, key: impl Into<SharedString>, when: Close) -> Stateful<Div> {
498    let key = key.into();
499    let button = theme
500        .ghost(ElementId::from(SharedString::from(format!(
501            "tab-close-{key}"
502        ))))
503        .flex_none()
504        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
505        .p(px(2.0))
506        .child(
507            crate::icons::icon(crate::icons::glyph::X)
508                .size(px(11.0))
509                .text_color(theme.text_muted),
510        );
511    match when {
512        Close::Always => button,
513        Close::OnHover => button
514            .invisible()
515            .group_hover(group_of(&key), |el| el.visible()),
516    }
517}
518
519/// The hover group a tab and its `×` share.
520fn group_of(key: &SharedString) -> SharedString {
521    SharedString::from(format!("tab-{key}"))
522}