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