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