1use gpui::prelude::*;
24use gpui::{div, px, Context, EventEmitter, IntoElement, SharedString, Window};
25
26use crate::theme::{theme, Size};
27use crate::{ActionIcon, CloseButton};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum TabBarEvent {
32 Select(usize),
34 Close(usize),
37 Add,
39}
40
41fn 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
51pub 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 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 pub fn active(mut self, index: usize) -> Self {
85 self.active = index;
86 self
87 }
88
89 pub fn with_add_button(mut self, show: bool) -> Self {
91 self.with_add_button = show;
92 self
93 }
94
95 pub fn active_index(&self) -> usize {
97 self.active
98 }
99
100 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 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 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 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 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 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 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 assert_eq!(active_after_remove(1, 1, 2), 1);
265 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}