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    BoxShadow, Div, ElementId, SharedString, Stateful, StyleRefinement, div, point, prelude::*, px,
38};
39
40use icons::Icon;
41use theme::{TextStyle, Theme, Typeset};
42
43use crate::widgets::{self, Buttons as _};
44
45mod bar;
46mod reorder;
47
48pub use bar::TabBar;
49
50pub use reorder::{Bar, Move, OutsideDrop, Reorder};
51
52/// An ordered set of tabs, one of them active.
53///
54/// `Id` is whatever names a tab to its owner — a counter, a path, a layout
55/// member. Identity is `PartialEq`, so an id that compares equal to one already
56/// in the strip is the same tab.
57///
58/// A strip with tabs in it always has one in front: [`Self::active`] is `None`
59/// only while [`Self::is_empty`].
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct Strip<Id> {
62    order: Vec<Id>,
63    active: Option<Id>,
64}
65
66impl<Id> Default for Strip<Id> {
67    fn default() -> Self {
68        Self {
69            order: Vec::new(),
70            active: None,
71        }
72    }
73}
74
75impl<Id: Clone + PartialEq> FromIterator<Id> for Strip<Id> {
76    /// The first of the run is the one in front.
77    fn from_iter<T: IntoIterator<Item = Id>>(iter: T) -> Self {
78        let order: Vec<Id> = iter.into_iter().collect();
79        let active = order.first().cloned();
80        Self { order, active }
81    }
82}
83
84impl<Id: Clone + PartialEq> Strip<Id> {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// The tabs, left to right.
90    pub fn tabs(&self) -> &[Id] {
91        &self.order
92    }
93
94    pub fn active(&self) -> Option<&Id> {
95        self.active.as_ref()
96    }
97
98    pub fn len(&self) -> usize {
99        self.order.len()
100    }
101
102    pub fn is_empty(&self) -> bool {
103        self.order.is_empty()
104    }
105
106    pub fn contains(&self, id: &Id) -> bool {
107        self.order.contains(id)
108    }
109
110    pub fn index_of(&self, id: &Id) -> Option<usize> {
111        self.order.iter().position(|held| held == id)
112    }
113
114    /// Bring `id` to the front, adding it at the end of the strip if it is not
115    /// already there. An id that *is* there keeps its place.
116    pub fn open(&mut self, id: Id) {
117        if !self.contains(&id) {
118            self.order.push(id.clone());
119        }
120        self.active = Some(id);
121    }
122
123    /// Bring an existing tab to the front. `false`, and nothing moves, when it
124    /// is not in the strip.
125    pub fn activate(&mut self, id: &Id) -> bool {
126        let held = self.contains(id);
127        if held {
128            self.active = Some(id.clone());
129        }
130        held
131    }
132
133    /// Take a tab out. `false` when it was not in the strip.
134    ///
135    /// Closing the tab in front hands the front to its right-hand neighbour,
136    /// or to the new last tab when it had none. Closing any other tab leaves
137    /// the front where it is.
138    pub fn close(&mut self, id: &Id) -> bool {
139        let Some(at) = self.index_of(id) else {
140            return false;
141        };
142        self.order.remove(at);
143        if self.active.as_ref() == Some(id) {
144            self.active = self.order.get(at).or_else(|| self.order.last()).cloned();
145        }
146        true
147    }
148
149    /// Step the front `step` tabs along, wrapping at both ends. An empty strip
150    /// does not move.
151    pub fn cycle(&mut self, step: isize) {
152        if self.order.is_empty() {
153            return;
154        }
155        let len = self.order.len() as isize;
156        let at = self
157            .active
158            .as_ref()
159            .and_then(|id| self.index_of(id))
160            .unwrap_or(0) as isize;
161        self.active = self
162            .order
163            .get((at + step).rem_euclid(len) as usize)
164            .cloned();
165    }
166
167    /// Move the tab at `from` so that it sits at `to`. Out-of-range ends are
168    /// ignored rather than clamped: a drag that left the strip did not mean the
169    /// last slot.
170    ///
171    /// The front is held by identity, so reordering never changes which tab is
172    /// in front.
173    pub fn reorder(&mut self, from: usize, to: usize) {
174        if from == to || from >= self.order.len() || to >= self.order.len() {
175            return;
176        }
177        let moved = self.order.remove(from);
178        self.order.insert(to, moved);
179    }
180
181    /// Carry `id` `travel` pixels along the strip from its slot, positive to
182    /// the right, passing every neighbour whose centre its own centre crosses.
183    ///
184    /// Returns the tabs passed, in the order passed, and the travel left over
185    /// past the new slot. `width` measures a tab; a tab it cannot measure is
186    /// not passed, and neither is anything beyond it.
187    pub fn carry(
188        &mut self,
189        id: &Id,
190        travel: f32,
191        width: impl Fn(&Id) -> Option<f32>,
192    ) -> (Vec<Id>, f32) {
193        let mut passed = Vec::new();
194        let (Some(mut at), Some(own)) = (self.index_of(id), width(id)) else {
195            return (passed, travel);
196        };
197        let mut travel = travel;
198        loop {
199            let next = match travel.partial_cmp(&0.0) {
200                Some(Ordering::Greater) => at + 1,
201                Some(Ordering::Less) if at > 0 => at - 1,
202                _ => break,
203            };
204            let Some(neighbour) = self.order.get(next).cloned() else {
205                break;
206            };
207            let Some(theirs) = width(&neighbour) else {
208                break;
209            };
210            if travel.abs() <= (own + theirs) / 2.0 + GAP {
211                break;
212            }
213            travel -= travel.signum() * (theirs + GAP);
214            self.order.swap(at, next);
215            passed.push(neighbour);
216            at = next;
217        }
218        (passed, travel)
219    }
220}
221
222/// What a tab shows.
223#[derive(Clone, Debug, Default)]
224pub struct Label {
225    text: SharedString,
226    icon: Option<Icon>,
227    badge: Option<SharedString>,
228    mark: Option<Icon>,
229}
230
231impl Label {
232    pub fn new(text: impl Into<SharedString>) -> Self {
233        Self {
234            text: text.into(),
235            ..Default::default()
236        }
237    }
238
239    /// A glyph before the text — what kind of thing the tab is on.
240    pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
241        self.icon = Some(icon.into());
242        self
243    }
244
245    /// A quiet trailing note: a line number, a reference, a count. It does not
246    /// truncate, so keep it to a few characters.
247    pub fn with_badge(mut self, badge: impl Into<SharedString>) -> Self {
248        self.badge = Some(badge.into());
249        self
250    }
251
252    /// A mark beside the label — unsaved work, a running job, something
253    /// unread.
254    ///
255    /// Painted at [`MARK_SIZE`] in the tab's own tone, outside the truncating
256    /// label so a long name cannot hide it. Lucide's round glyphs are outlines;
257    /// [`Icon::solid`] fills one.
258    pub fn mark(mut self, mark: impl Into<Icon>) -> Self {
259        self.mark = Some(mark.into());
260        self
261    }
262}
263
264/// How a tab reads.
265///
266/// `Front` and `Focused` are separate because a window can hold several strips:
267/// a background pane's own front tab still has to say what is under it, while
268/// only one tab in the window has the keyboard.
269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
270pub enum State {
271    Resting,
272    Front,
273    Focused,
274}
275
276/// When a tab's `×` is on show.
277#[derive(Clone, Copy, Debug, PartialEq, Eq)]
278pub enum Close {
279    Always,
280    OnHover,
281}
282
283/// The edge of a tab a drop lands on.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub enum Side {
286    Leading,
287    Trailing,
288}
289
290impl Side {
291    /// Where a tab moved from `from` to `to` lands on the tab whose place it
292    /// takes: its trailing edge moving right, its leading edge moving left.
293    /// `None` when it stays put.
294    pub fn of(from: usize, to: usize) -> Option<Self> {
295        match from.cmp(&to) {
296            Ordering::Less => Some(Self::Trailing),
297            Ordering::Greater => Some(Self::Leading),
298            Ordering::Equal => None,
299        }
300    }
301}
302
303/// A [`tab`]'s drop mark, for its `drag_over` style: a 1px accent line on
304/// `side`, in the gap beside the tab. A shadow, so the tab's layout does not
305/// move.
306pub fn drop_mark(style: StyleRefinement, side: Side, theme: &Theme) -> StyleRefinement {
307    let x = match side {
308        Side::Leading => px(-1.0),
309        Side::Trailing => px(1.0),
310    };
311    style.shadow(vec![BoxShadow {
312        color: theme.accent.opacity(0.6),
313        offset: point(x, px(0.0)),
314        blur_radius: px(0.0),
315        spread_radius: px(0.0),
316        inset: false,
317    }])
318}
319
320/// How wide one tab grows before its label truncates.
321pub const MAX_WIDTH: f32 = 180.0;
322
323/// The box [`Label::mark`] paints in. Lucide's `circle-small` inks 14 of its 24
324/// units, which puts a solid one at 6px across.
325pub const MARK_SIZE: f32 = 10.0;
326
327/// Gap between tabs.
328const GAP: f32 = 2.0;
329/// Inset at a tab's ends, and the gap between what it holds.
330const TAB_PAD: f32 = 8.0;
331
332/// The strip. Tabs go in it; a `+`, a `···` and anything else on the row are
333/// the caller's, outside this.
334///
335/// It scrolls sideways once the tabs no longer fit. `min_w_0` is what allows
336/// that — a flex child's `min-width: auto` refuses to shrink below its content,
337/// so without it the strip grows past its row instead of scrolling.
338pub fn bar(id: impl Into<ElementId>) -> TabBar {
339    TabBar::new(id.into())
340}
341
342/// One tab, up to the `×`: pass the same `key` to [`close`] and chain the
343/// result on as a child.
344///
345/// `key` names both the element and the hover group [`Close::OnHover`] reads,
346/// so the two are derived from one string rather than written twice.
347///
348/// Every tab but a [`State::Focused`] one takes its own `hover`, and gpui panics
349/// on a second one: reach for [`Close::OnHover`]'s group, or a `group_hover` of
350/// your own, rather than chaining `.hover(..)` onto what this returns.
351pub fn tab(
352    theme: &Theme,
353    key: impl Into<SharedString>,
354    label: Label,
355    state: State,
356) -> Stateful<Div> {
357    let key = key.into();
358    let group = group_of(&key);
359    let tint = match state {
360        State::Resting => theme.text_muted,
361        State::Front | State::Focused => theme.text,
362    };
363    let wash = theme.element_hover;
364    div()
365        .id(ElementId::from(SharedString::from(format!("tab-{key}"))))
366        .group(group)
367        // Sized to its label, not to the bar: a tab stretched across the strip
368        // reads as a field rather than a label.
369        .flex_none()
370        .flex()
371        .flex_row()
372        .items_center()
373        .gap(px(6.0))
374        .h(px(Theme::BUTTON_HEIGHT))
375        .max_w(px(MAX_WIDTH))
376        .px(px(TAB_PAD))
377        .rounded(px(Theme::control_radius()))
378        // The slot `focus::focusable` fills, kept whether or not it is filled:
379        // gpui sizes border-box, so a border that arrived with the ring would
380        // shift the label by a pixel.
381        .border_1()
382        .border_color(widgets::RING_SLOT)
383        .text_style(TextStyle::Callout)
384        .text_color(tint)
385        .cursor_pointer()
386        .when(state == State::Focused, |el| el.bg(theme.element_active))
387        .when(state != State::Focused, |el| {
388            el.hover(move |el| el.bg(wash))
389        })
390        .children(label.icon.map(|icon| {
391            crate::icons::icon(icon)
392                .size(px(14.0))
393                .flex_none()
394                .text_color(theme.text_muted)
395        }))
396        .child(div().min_w_0().truncate().child(label.text))
397        .children(label.mark.map(|mark| {
398            crate::icons::icon(mark)
399                .size(px(MARK_SIZE))
400                .flex_none()
401                .text_color(tint)
402        }))
403        .children(label.badge.map(|badge| {
404            div()
405                .flex_none()
406                .text_style(TextStyle::Caption)
407                .text_color(theme.text_faint)
408                .child(badge)
409        }))
410}
411
412/// A tab's `×`, for the `key` its [`tab`] was built with. The click is the
413/// caller's, and has to stop propagating or the tab under it takes the press
414/// as well.
415pub fn close(theme: &Theme, key: impl Into<SharedString>, when: Close) -> Stateful<Div> {
416    let key = key.into();
417    let button = theme
418        .ghost(ElementId::from(SharedString::from(format!(
419            "tab-close-{key}"
420        ))))
421        .flex_none()
422        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
423        .p(px(2.0))
424        .child(
425            crate::icons::icon(crate::icons::glyph::X)
426                .size(px(11.0))
427                .text_color(theme.text_muted),
428        );
429    match when {
430        Close::Always => button,
431        Close::OnHover => button
432            .invisible()
433            .group_hover(group_of(&key), |el| el.visible()),
434    }
435}
436
437/// The hover group a tab and its `×` share.
438fn group_of(key: &SharedString) -> SharedString {
439    SharedString::from(format!("tab-{key}"))
440}