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