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                        .size(Size::Sm)
243                        .on_click(cx.listener(|_this, _ev, _window, cx| cx.emit(TabBarEvent::Add))),
244                ),
245            );
246        }
247
248        bar.probe("TabBar")
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::active_after_remove;
255
256    #[test]
257    fn removing_before_active_shifts_it_left() {
258        assert_eq!(active_after_remove(2, 0, 3), 1);
259        assert_eq!(active_after_remove(3, 2, 3), 2);
260    }
261
262    #[test]
263    fn removing_the_active_tab_keeps_its_slot_clamped() {
264        // [a b c] active=1, remove 1 -> [a c] active=1 (c takes the slot).
265        assert_eq!(active_after_remove(1, 1, 2), 1);
266        // Removing the last while it is active clamps to the new tail.
267        assert_eq!(active_after_remove(2, 2, 2), 1);
268    }
269
270    #[test]
271    fn removing_after_active_leaves_it_alone() {
272        assert_eq!(active_after_remove(0, 2, 2), 0);
273        assert_eq!(active_after_remove(1, 3, 3), 1);
274    }
275
276    #[test]
277    fn emptying_the_bar_resets_to_zero() {
278        assert_eq!(active_after_remove(0, 0, 0), 0);
279    }
280}