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. Whether the
150 /// front changed: an empty strip, a lone tab and a whole lap do not move.
151 pub fn cycle(&mut self, step: isize) -> bool {
152 if self.order.is_empty() {
153 return false;
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 let next = self
162 .order
163 .get((at + step).rem_euclid(len) as usize)
164 .cloned();
165 if next == self.active {
166 return false;
167 }
168 self.active = next;
169 true
170 }
171
172 /// Move the tab at `from` so that it sits at `to`. Out-of-range ends are
173 /// ignored rather than clamped: a drag that left the strip did not mean the
174 /// last slot.
175 ///
176 /// The front is held by identity, so reordering never changes which tab is
177 /// in front.
178 pub fn reorder(&mut self, from: usize, to: usize) {
179 if from == to || from >= self.order.len() || to >= self.order.len() {
180 return;
181 }
182 let moved = self.order.remove(from);
183 self.order.insert(to, moved);
184 }
185
186 /// Carry `id` `travel` pixels along the strip from its slot, positive to
187 /// the right, passing every neighbour whose centre its own centre crosses.
188 ///
189 /// Returns the tabs passed, in the order passed, and the travel left over
190 /// past the new slot. `width` measures a tab; a tab it cannot measure is
191 /// not passed, and neither is anything beyond it.
192 pub fn carry(
193 &mut self,
194 id: &Id,
195 travel: f32,
196 width: impl Fn(&Id) -> Option<f32>,
197 ) -> (Vec<Id>, f32) {
198 let mut passed = Vec::new();
199 let (Some(mut at), Some(own)) = (self.index_of(id), width(id)) else {
200 return (passed, travel);
201 };
202 let mut travel = travel;
203 loop {
204 let next = match travel.partial_cmp(&0.0) {
205 Some(Ordering::Greater) => at + 1,
206 Some(Ordering::Less) if at > 0 => at - 1,
207 _ => break,
208 };
209 let Some(neighbour) = self.order.get(next).cloned() else {
210 break;
211 };
212 let Some(theirs) = width(&neighbour) else {
213 break;
214 };
215 if travel.abs() <= (own + theirs) / 2.0 + GAP {
216 break;
217 }
218 travel -= travel.signum() * (theirs + GAP);
219 self.order.swap(at, next);
220 passed.push(neighbour);
221 at = next;
222 }
223 (passed, travel)
224 }
225}
226
227/// What a tab shows.
228#[derive(Clone, Debug, Default)]
229pub struct Label {
230 text: SharedString,
231 icon: Option<Icon>,
232 badge: Option<SharedString>,
233 mark: Option<Icon>,
234}
235
236impl Label {
237 pub fn new(text: impl Into<SharedString>) -> Self {
238 Self {
239 text: text.into(),
240 ..Default::default()
241 }
242 }
243
244 /// A glyph before the text — what kind of thing the tab is on.
245 pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
246 self.icon = Some(icon.into());
247 self
248 }
249
250 /// A quiet trailing note: a line number, a reference, a count. It does not
251 /// truncate, so keep it to a few characters.
252 pub fn with_badge(mut self, badge: impl Into<SharedString>) -> Self {
253 self.badge = Some(badge.into());
254 self
255 }
256
257 /// A mark beside the label — unsaved work, a running job, something
258 /// unread.
259 ///
260 /// Painted at [`MARK_SIZE`] in the tab's own tone, outside the truncating
261 /// label so a long name cannot hide it. Lucide's round glyphs are outlines;
262 /// [`Icon::solid`] fills one.
263 pub fn mark(mut self, mark: impl Into<Icon>) -> Self {
264 self.mark = Some(mark.into());
265 self
266 }
267}
268
269/// How a tab reads.
270///
271/// `Front` and `Focused` are separate because a window can hold several strips:
272/// a background pane's own front tab still has to say what is under it, while
273/// only one tab in the window has the keyboard.
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub enum State {
276 /// Behind another tab of its strip.
277 Resting,
278 /// In front of its strip, in a pane without the keyboard.
279 Front,
280 /// The tab holding the keyboard. One per window.
281 Focused,
282}
283
284/// What a reorderable strip's tabs stand on — see [`Bar::on_track`]. A track
285/// and its tabs are capsules, a [`Track::Flat`]'s rounded as a control is; the
286/// tabs share its width equally down to
287/// [`TRACK_MIN_WIDTH`], their labels centred, and a line parts two resting
288/// neighbours.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub enum Track {
291 /// The segmented control's track, its border drawn.
292 Bordered,
293 /// The same wash with no border.
294 Bare,
295 /// No wash and no border.
296 Flat,
297}
298
299/// When a tab's `×` is on show.
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub enum Close {
302 Always,
303 OnHover,
304}
305
306/// The edge of a tab a drop lands on.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum Side {
309 Leading,
310 Trailing,
311}
312
313impl Side {
314 /// Where a tab moved from `from` to `to` lands on the tab whose place it
315 /// takes: its trailing edge moving right, its leading edge moving left.
316 /// `None` when it stays put.
317 pub fn of(from: usize, to: usize) -> Option<Self> {
318 match from.cmp(&to) {
319 Ordering::Less => Some(Self::Trailing),
320 Ordering::Greater => Some(Self::Leading),
321 Ordering::Equal => None,
322 }
323 }
324}
325
326/// A [`tab`]'s drop mark, for its `drag_over` style: a 1px accent line on
327/// `side`, in the gap beside the tab. A shadow, so the tab's layout does not
328/// move.
329pub fn drop_mark(style: StyleRefinement, side: Side, theme: &Theme) -> StyleRefinement {
330 let x = match side {
331 Side::Leading => px(-1.0),
332 Side::Trailing => px(1.0),
333 };
334 style.shadow(vec![BoxShadow {
335 color: theme.accent.opacity(0.6),
336 offset: point(x, px(0.0)),
337 blur_radius: px(0.0),
338 spread_radius: px(0.0),
339 inset: false,
340 }])
341}
342
343/// How wide one tab grows before its label truncates.
344pub const MAX_WIDTH: f32 = 180.0;
345
346/// The narrowest a tab on a [`Track`] gets before its strip scrolls.
347pub const TRACK_MIN_WIDTH: f32 = 120.0;
348
349/// How tall a [`Track`] stands. Its tabs fill it inside its border and
350/// [`TRACK_PAD`].
351pub const TRACK_HEIGHT: f32 = 30.0;
352
353/// The padding between a [`Track`]'s border and its tabs.
354const TRACK_PAD: f32 = 1.0;
355
356/// The box [`Label::mark`] paints in. Lucide's `circle-small` inks 14 of its 24
357/// units, which puts a solid one at 6px across.
358pub const MARK_SIZE: f32 = 10.0;
359
360/// Gap between tabs.
361const GAP: f32 = 2.0;
362/// Inset at a tab's ends, and the gap between what it holds.
363const TAB_PAD: f32 = 8.0;
364
365/// The strip. Tabs go in it; a `+`, a `···` and anything else on the row are
366/// the caller's, outside this.
367///
368/// It scrolls sideways once the tabs no longer fit, by wheel and trackpad; it
369/// draws no scrollbar. Without [`TabBar::track_scroll`] it keeps its offset
370/// across renders under `id`.
371/// `min_w_0` is what allows
372/// that — a flex child's `min-width: auto` refuses to shrink below its content,
373/// so without it the strip grows past its row instead of scrolling.
374pub fn bar(id: impl Into<ElementId>) -> TabBar {
375 TabBar::new(id.into())
376}
377
378/// One tab, up to the `×`: pass the same `key` to [`close`] and chain the
379/// result on as a child.
380///
381/// `key` names both the element and the hover group [`Close::OnHover`] reads,
382/// so the two are derived from one string rather than written twice.
383///
384/// Every tab but a [`State::Focused`] one takes its own `hover`, and gpui panics
385/// on a second one: reach for [`Close::OnHover`]'s group, or a `group_hover` of
386/// your own, rather than chaining `.hover(..)` onto what this returns.
387pub fn tab(
388 theme: &Theme,
389 key: impl Into<SharedString>,
390 label: Label,
391 state: State,
392) -> Stateful<Div> {
393 let key = key.into();
394 let group = group_of(&key);
395 let tint = match state {
396 State::Resting => theme.text_muted,
397 State::Front | State::Focused => theme.text,
398 };
399 let wash = theme.element_hover;
400 div()
401 .id(ElementId::from(SharedString::from(format!("tab-{key}"))))
402 .group(group)
403 // Sized to its label, not to the bar: a tab stretched across the strip
404 // reads as a field rather than a label.
405 .flex_none()
406 .flex()
407 .flex_row()
408 .items_center()
409 .gap(px(6.0))
410 .h(px(Theme::BUTTON_HEIGHT))
411 .max_w(px(MAX_WIDTH))
412 .px(px(TAB_PAD))
413 .rounded(px(Theme::control_radius()))
414 // The slot `focus::focusable` fills, kept whether or not it is filled:
415 // gpui sizes border-box, so a border that arrived with the ring would
416 // shift the label by a pixel.
417 .border_1()
418 .border_color(widgets::RING_SLOT)
419 .text_style(TextStyle::Callout)
420 .text_color(tint)
421 .cursor_pointer()
422 .when(state == State::Focused, |el| {
423 el.bg(theme.card_selected_bg())
424 .shadow(theme::glass_selected_shadows())
425 })
426 .when(state != State::Focused, |el| {
427 el.hover(move |el| el.bg(wash))
428 })
429 .children(label.icon.map(|icon| {
430 crate::icons::icon(icon)
431 .size(px(14.0))
432 .flex_none()
433 .text_color(theme.text_muted)
434 }))
435 .child(div().min_w_0().truncate().child(label.text))
436 .children(label.mark.map(|mark| {
437 crate::icons::icon(mark)
438 .size(px(MARK_SIZE))
439 .flex_none()
440 .text_color(tint)
441 }))
442 .children(label.badge.map(|badge| {
443 div()
444 .flex_none()
445 .text_style(TextStyle::Caption)
446 .text_color(theme.text_faint)
447 .child(badge)
448 }))
449}
450
451/// A tab's `×`, for the `key` its [`tab`] was built with. The click is the
452/// caller's. It stops mouse-down propagation, so a press on it neither clicks
453/// nor picks up the tab under it.
454pub fn close(theme: &Theme, key: impl Into<SharedString>, when: Close) -> Stateful<Div> {
455 let key = key.into();
456 let button = theme
457 .ghost(ElementId::from(SharedString::from(format!(
458 "tab-close-{key}"
459 ))))
460 .flex_none()
461 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
462 .p(px(2.0))
463 .child(
464 crate::icons::icon(crate::icons::glyph::X)
465 .size(px(11.0))
466 .text_color(theme.text_muted),
467 );
468 match when {
469 Close::Always => button,
470 Close::OnHover => button
471 .invisible()
472 .group_hover(group_of(&key), |el| el.visible()),
473 }
474}
475
476/// The hover group a tab and its `×` share.
477fn group_of(key: &SharedString) -> SharedString {
478 SharedString::from(format!("tab-{key}"))
479}