Skip to main content

guise/data/
tabbar.rs

1//! `TabBar` — a document-style tab strip (gpui entity).
2//!
3//! Owns the tab list and active index. Renders a horizontally scrollable
4//! strip of tabs, each with a close button (shown while hovered or active),
5//! plus an optional trailing add button. Emits [`TabBarEvent`] for selection,
6//! close, and add clicks — closing does **not** remove the tab by itself, the
7//! parent decides (e.g. after an unsaved-changes prompt) and calls
8//! [`TabBar::remove_tab`].
9//!
10//! ```ignore
11//! let bar = cx.new(|cx| TabBar::new(cx).tabs(["main.rs", "lib.rs"]).active(0));
12//! cx.subscribe(&bar, |_this, bar, event: &TabBarEvent, cx| match event {
13//!     TabBarEvent::Close(i) => {
14//!         let i = *i;
15//!         bar.update(cx, |b, cx| b.remove_tab(i, cx));
16//!     }
17//!     TabBarEvent::Add => bar.update(cx, |b, cx| b.add_tab("untitled", cx)),
18//!     TabBarEvent::Select(_) => {}
19//! })
20//! .detach();
21//! ```
22
23use gpui::prelude::*;
24use gpui::{div, px, Context, EventEmitter, IntoElement, SharedString, Window};
25
26use crate::devtools::Probed;
27use crate::theme::{theme, Size};
28use crate::{ActionIcon, CloseButton};
29
30/// Emitted by [`TabBar`] on user interaction.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TabBarEvent {
33  /// A tab was clicked; carries its index. The bar has already switched to it.
34  Select(usize),
35  /// A tab's close button was clicked; carries its index. The tab is *not*
36  /// removed automatically — call [`TabBar::remove_tab`] to drop it.
37  Close(usize),
38  /// The trailing `+` button was clicked.
39  Add,
40}
41
42/// Where the active index lands after removing `removed` from a list that is
43/// now `new_len` items long.
44fn active_after_remove(active: usize, removed: usize, new_len: usize) -> usize {
45  if new_len == 0 {
46    return 0;
47  }
48  let shifted = if removed < active { active - 1 } else { active };
49  shifted.min(new_len - 1)
50}
51
52/// A document-style tab strip. Create with
53/// `cx.new(|cx| TabBar::new(cx).tabs(["one", "two"]))`.
54pub struct TabBar {
55  tabs: Vec<SharedString>,
56  active: usize,
57  hovered: Option<usize>,
58  with_add_button: bool,
59}
60
61impl EventEmitter<TabBarEvent> for TabBar {}
62
63impl TabBar {
64  pub fn new(_cx: &mut Context<Self>) -> Self {
65    TabBar {
66      tabs: Vec::new(),
67      active: 0,
68      hovered: None,
69      with_add_button: true,
70    }
71  }
72
73  /// Replace the tab labels (builder form; see [`TabBar::set_tabs`] for the
74  /// post-construction method).
75  pub fn tabs<I, S>(mut self, tabs: I) -> Self
76  where
77    I: IntoIterator<Item = S>,
78    S: Into<SharedString>,
79  {
80    self.tabs = tabs.into_iter().map(Into::into).collect();
81    self
82  }
83
84  /// The initially active tab.
85  pub fn active(mut self, index: usize) -> Self {
86    self.active = index;
87    self
88  }
89
90  /// Show the trailing `+` button (default `true`).
91  pub fn with_add_button(mut self, show: bool) -> Self {
92    self.with_add_button = show;
93    self
94  }
95
96  /// The index of the active tab.
97  pub fn active_index(&self) -> usize {
98    self.active
99  }
100
101  /// Number of tabs.
102  pub fn len(&self) -> usize {
103    self.tabs.len()
104  }
105
106  pub fn is_empty(&self) -> bool {
107    self.tabs.is_empty()
108  }
109
110  /// Append a tab and make it active. Does not emit an event.
111  pub fn add_tab(&mut self, label: impl Into<SharedString>, cx: &mut Context<Self>) {
112    self.tabs.push(label.into());
113    self.active = self.tabs.len() - 1;
114    cx.notify();
115  }
116
117  /// Remove the tab at `index` (no-op when out of range), keeping the
118  /// active selection on the same document where possible. Does not emit.
119  pub fn remove_tab(&mut self, index: usize, cx: &mut Context<Self>) {
120    if index >= self.tabs.len() {
121      return;
122    }
123    self.tabs.remove(index);
124    self.active = active_after_remove(self.active, index, self.tabs.len());
125    self.hovered = None;
126    cx.notify();
127  }
128
129  /// Replace every tab, clamping the active index. Does not emit.
130  pub fn set_tabs(&mut self, tabs: Vec<SharedString>, cx: &mut Context<Self>) {
131    self.tabs = tabs;
132    self.active = self.active.min(self.tabs.len().saturating_sub(1));
133    self.hovered = None;
134    cx.notify();
135  }
136
137  /// Programmatically switch tabs (clamped). Does not emit.
138  pub fn set_active(&mut self, index: usize, cx: &mut Context<Self>) {
139    let clamped = index.min(self.tabs.len().saturating_sub(1));
140    if self.active != clamped {
141      self.active = clamped;
142      cx.notify();
143    }
144  }
145}
146
147impl Render for TabBar {
148  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
149    let t = theme(cx);
150    let surface = t.surface().hsla();
151    let strip_bg = t.surface_hover().hsla();
152    let border = t.border().hsla();
153    let text = t.text().hsla();
154    let dimmed = t.dimmed().hsla();
155    let font = t.font_size(Size::Sm);
156
157    let count = self.tabs.len();
158    let active = if count == 0 {
159      0
160    } else {
161      self.active.min(count - 1)
162    };
163    let hovered = self.hovered;
164
165    let mut strip = div()
166      .id("guise-tabbar-strip")
167      .flex_1()
168      .min_w(px(0.0))
169      .flex()
170      .overflow_x_scroll();
171
172    for (i, label) in self.tabs.iter().enumerate() {
173      let is_active = i == active;
174      let show_close = is_active || hovered == Some(i);
175
176      // The close button keeps its slot when hidden so tab widths stay
177      // stable; a hidden div paints nothing (no hitbox, no clicks).
178      let mut close_slot = div().flex_none();
179      if !show_close {
180        close_slot = close_slot.invisible();
181      }
182      close_slot = close_slot.child(
183        CloseButton::new(("guise-tabbar-close", i))
184          .size(Size::Xs)
185          .on_click(cx.listener(move |_this, _ev, _window, cx| {
186            // Don't let the click bubble into the tab (which
187            // would also select it).
188            cx.stop_propagation();
189            cx.emit(TabBarEvent::Close(i));
190          })),
191      );
192
193      let mut tab = div()
194        .id(("guise-tabbar-tab", i))
195        .flex_none()
196        .flex()
197        .items_center()
198        .gap(px(6.0))
199        .pl(px(12.0))
200        .pr(px(6.0))
201        .py(px(6.0))
202        .border_r_1()
203        .border_color(border)
204        .text_size(px(font))
205        .text_color(if is_active { text } else { dimmed })
206        .child(label.clone())
207        .child(close_slot)
208        .on_hover(cx.listener(move |this, entered: &bool, _window, cx| {
209          if *entered {
210            this.hovered = Some(i);
211          } else if this.hovered == Some(i) {
212            this.hovered = None;
213          }
214          cx.notify();
215        }))
216        .on_click(cx.listener(move |this, _ev, _window, cx| {
217          this.active = i;
218          cx.emit(TabBarEvent::Select(i));
219          cx.notify();
220        }));
221      if is_active {
222        tab = tab.bg(surface);
223      } else {
224        tab = tab.hover(move |s| s.text_color(text));
225      }
226      strip = strip.child(tab);
227    }
228
229    let mut bar = div()
230      .flex()
231      .items_center()
232      .w_full()
233      .bg(strip_bg)
234      .border_b_1()
235      .border_color(border)
236      .child(strip);
237
238    if self.with_add_button {
239      bar = bar.child(
240        div().flex_none().px(px(4.0)).child(
241          ActionIcon::new("guise-tabbar-add", "+")
242            .label("Add tab")
243            .size(Size::Sm)
244            .on_click(cx.listener(|_this, _ev, _window, cx| cx.emit(TabBarEvent::Add))),
245        ),
246      );
247    }
248
249    bar.probe("TabBar")
250  }
251}
252
253#[cfg(test)]
254mod tests {
255  use super::active_after_remove;
256
257  #[test]
258  fn removing_before_active_shifts_it_left() {
259    assert_eq!(active_after_remove(2, 0, 3), 1);
260    assert_eq!(active_after_remove(3, 2, 3), 2);
261  }
262
263  #[test]
264  fn removing_the_active_tab_keeps_its_slot_clamped() {
265    // [a b c] active=1, remove 1 -> [a c] active=1 (c takes the slot).
266    assert_eq!(active_after_remove(1, 1, 2), 1);
267    // Removing the last while it is active clamps to the new tail.
268    assert_eq!(active_after_remove(2, 2, 2), 1);
269  }
270
271  #[test]
272  fn removing_after_active_leaves_it_alone() {
273    assert_eq!(active_after_remove(0, 2, 2), 0);
274    assert_eq!(active_after_remove(1, 3, 3), 1);
275  }
276
277  #[test]
278  fn emptying_the_bar_resets_to_zero() {
279    assert_eq!(active_after_remove(0, 0, 0), 0);
280  }
281}