1use 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TabBarEvent {
33 Select(usize),
35 Close(usize),
38 Add,
40}
41
42fn 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
52pub 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 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 pub fn active(mut self, index: usize) -> Self {
86 self.active = index;
87 self
88 }
89
90 pub fn with_add_button(mut self, show: bool) -> Self {
92 self.with_add_button = show;
93 self
94 }
95
96 pub fn active_index(&self) -> usize {
98 self.active
99 }
100
101 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 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 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 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 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 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 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 assert_eq!(active_after_remove(1, 1, 2), 1);
267 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}