1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4 Anchor, AnyElement, App, Background, Bounds, Edges, ElementId, InteractiveElement, IntoElement,
5 ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _,
6 StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px,
7};
8use gpui_base::spring;
9use rust_i18n::t;
10use smallvec::SmallVec;
11
12use super::{Tab, TabVariant};
13use crate::button::{Button, ButtonVariants as _};
14use crate::menu::{DropdownMenu as _, PopupMenuItem};
15use crate::{
16 ActiveTheme, ElementExt, Icon, InteractiveElementExt as _, Selectable, Sizable, Size,
17 StyledExt, h_flex, styled::raised_shadow,
18};
19
20struct TabIndicatorBounds {
21 container: Bounds<Pixels>,
22 tabs: Vec<Bounds<Pixels>>,
23}
24
25impl TabIndicatorBounds {
26 fn new(num_tabs: usize) -> Self {
27 Self {
28 container: Bounds::default(),
29 tabs: vec![Bounds::default(); num_tabs],
30 }
31 }
32
33 fn resize(&mut self, num_tabs: usize) {
34 self.tabs.resize(num_tabs, Bounds::default());
35 }
36}
37
38#[derive(IntoElement)]
40pub struct TabBar {
41 id: ElementId,
42 base: gpui_base::Tabs,
43 style: StyleRefinement,
44 scroll_handle: Option<ScrollHandle>,
45 prefix: Option<AnyElement>,
46 suffix: Option<AnyElement>,
47 children: SmallVec<[Tab; 2]>,
48 last_empty_space: AnyElement,
49 selected_index: Option<usize>,
50 variant: TabVariant,
51 size: Size,
52 menu: bool,
53 max_width: Option<Pixels>,
54 on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
55}
56
57impl TabBar {
58 pub fn new(id: impl Into<ElementId>) -> Self {
60 let id = id.into();
61 Self {
62 id: id.clone(),
63 base: gpui_base::Tabs::new(id).px(px(-1.)),
64 style: StyleRefinement::default(),
65 children: SmallVec::new(),
66 scroll_handle: None,
67 prefix: None,
68 suffix: None,
69 variant: TabVariant::default(),
70 size: Size::default(),
71 last_empty_space: div().w_3().into_any_element(),
72 selected_index: None,
73 on_click: None,
74 menu: false,
75 max_width: None,
76 }
77 }
78
79 pub fn with_variant(mut self, variant: TabVariant) -> Self {
81 self.variant = variant;
82 self
83 }
84
85 pub fn pill(mut self) -> Self {
87 self.variant = TabVariant::Pill;
88 self
89 }
90
91 pub fn outline(mut self) -> Self {
93 self.variant = TabVariant::Outline;
94 self
95 }
96
97 pub fn segmented(mut self) -> Self {
99 self.variant = TabVariant::Segmented;
100 self
101 }
102
103 pub fn underline(mut self) -> Self {
105 self.variant = TabVariant::Underline;
106 self
107 }
108
109 pub fn menu(mut self, menu: bool) -> Self {
111 self.menu = menu;
112 self
113 }
114
115 pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
119 self.max_width = Some(width.into());
120 self
121 }
122
123 pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
125 self.scroll_handle = Some(scroll_handle.clone());
126 self
127 }
128
129 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
131 self.prefix = Some(prefix.into_any_element());
132 self
133 }
134
135 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
137 self.suffix = Some(suffix.into_any_element());
138 self
139 }
140
141 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
143 self.children.extend(children.into_iter().map(Into::into));
144 self
145 }
146
147 pub fn child(mut self, child: impl Into<Tab>) -> Self {
149 self.children.push(child.into());
150 self
151 }
152
153 pub fn selected_index(mut self, index: usize) -> Self {
155 self.selected_index = Some(index);
156 self
157 }
158
159 pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
161 self.last_empty_space = last_empty_space.into_any_element();
162 self
163 }
164
165 pub fn on_click<F>(mut self, on_click: F) -> Self
169 where
170 F: Fn(&usize, &mut Window, &mut App) + 'static,
171 {
172 self.on_click = Some(Rc::new(on_click));
173 self
174 }
175
176 fn render_indicator(
183 &self,
184 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
185 inset: Pixels,
186 window: &mut Window,
187 cx: &mut App,
188 ) -> Option<(AnyElement, u64)> {
189 let has_indicator = matches!(
190 self.variant,
191 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
192 );
193 let num_tabs = self.children.len();
194 let selected_ix = self.selected_index.unwrap_or(usize::MAX);
195
196 if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
197 return None;
198 }
199
200 let prev_key = format!("{}-tab-prev", self.id);
201 let anim_key = format!("{}-tab-anim", self.id);
202 let init_key = format!("{}-tab-init", self.id);
203
204 let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
205 let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
207 let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
208
209 if !*initialized.read(cx) {
211 initialized.update(cx, |v, _| *v = true);
212 }
213
214 self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
215
216 let (to_left, to_width, epoch) = *anim_params.read(cx);
217 if to_width <= px(0.) {
218 return None;
219 }
220
221 let indicator_key = format!("{}-tab-indicator", self.id);
225 let left = spring(
226 (indicator_key.clone(), "left"),
227 to_left,
228 cx.theme().motion_tokens().spring_move,
229 window,
230 cx,
231 );
232 let width = spring(
233 (indicator_key, "width"),
234 to_width,
235 cx.theme().motion_tokens().spring_move,
236 window,
237 cx,
238 );
239
240 let variant = self.variant;
241 let size = self.size;
242 let inner_height = variant.inner_height(size);
243 let inner_radius = variant.inner_radius(size, cx);
244
245 let indicator = div()
246 .absolute()
247 .top_0()
248 .bottom_0()
249 .left(left + inset)
250 .w(width)
251 .map(|el| match variant {
252 TabVariant::Segmented => el.flex().items_center().child(
253 div()
254 .w_full()
255 .h(inner_height)
256 .bg(cx.theme().tokens.background)
257 .rounded(inner_radius)
258 .shadow(raised_shadow()),
259 ),
260 TabVariant::Pill => el.flex().items_center().child(
261 div()
262 .size_full()
263 .bg(cx.theme().tokens.primary)
264 .rounded(cx.theme().radius_full()),
265 ),
266 TabVariant::Underline => el.child(
267 div()
268 .absolute()
269 .left_0()
270 .right_0()
271 .bottom_0()
272 .h(px(2.))
273 .bg(cx.theme().tokens.primary),
274 ),
275 _ => el,
276 });
277
278 Some((indicator.into_any_element(), epoch))
279 }
280
281 fn update_anim_params(
283 &self,
284 selected_ix: usize,
285 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
286 prev_selected: &gpui::Entity<usize>,
287 anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
288 cx: &mut App,
289 ) {
290 let rc = match bounds_rc {
291 Some(rc) => rc,
292 None => return,
293 };
294
295 let prev_ix = *prev_selected.read(cx);
296 let bounds = rc.borrow();
297 let container = bounds.container;
298
299 if container.size.width == px(0.) {
300 if prev_ix != selected_ix {
301 prev_selected.update(cx, |v, _| *v = selected_ix);
302 }
303 return;
304 }
305
306 if prev_ix != selected_ix {
307 if let Some(to_b) = bounds.tabs.get(selected_ix) {
308 let left = to_b.origin.x - container.origin.x;
309 let width = to_b.size.width;
310 let epoch = anim_params.read(cx).2;
313 let epoch = match bounds.tabs.get(prev_ix) {
314 Some(_) => epoch + 1,
315 None => epoch,
316 };
317 anim_params.update(cx, |v, _| *v = (left, width, epoch));
318 }
319 drop(bounds);
320 prev_selected.update(cx, |v, _| *v = selected_ix);
321 return;
322 }
323
324 if let Some(to_b) = bounds.tabs.get(selected_ix) {
325 let left = to_b.origin.x - container.origin.x;
326 let width = to_b.size.width;
327 let (to_left, to_width, epoch) = *anim_params.read(cx);
328
329 if left != to_left || width != to_width {
330 anim_params.update(cx, |v, _| *v = (left, width, epoch));
331 }
332 }
333 }
334}
335
336impl Styled for TabBar {
337 fn style(&mut self) -> &mut StyleRefinement {
338 &mut self.style
339 }
340}
341
342impl Sizable for TabBar {
343 fn with_size(mut self, size: impl Into<Size>) -> Self {
344 self.size = size.into();
345 self
346 }
347}
348
349impl RenderOnce for TabBar {
350 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
351 let default_gap = match self.size {
352 Size::Small | Size::XSmall => px(8.),
353 Size::Large => px(16.),
354 _ => px(12.),
355 };
356 let (bg, paddings, gap): (Background, _, _) = match self.variant {
357 TabVariant::Tab => {
358 let padding = Edges::all(px(0.));
359 (cx.theme().tokens.tab_bar.into(), padding, px(0.))
360 }
361 TabVariant::Outline => {
362 let padding = Edges::all(px(0.));
363 (cx.theme().transparent.into(), padding, default_gap)
364 }
365 TabVariant::Pill => {
366 let padding = Edges::all(px(0.));
367 (cx.theme().transparent.into(), padding, px(4.))
368 }
369 TabVariant::Segmented => {
370 let padding_x = match self.size {
371 Size::XSmall => px(2.),
372 Size::Small => px(3.),
373 _ => px(4.),
374 };
375 let padding = Edges {
376 left: padding_x,
377 right: padding_x,
378 ..Default::default()
379 };
380
381 (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
382 }
383 TabVariant::Underline => {
384 let gap = match self.size {
386 Size::XSmall => px(10.),
387 Size::Small => px(12.),
388 Size::Large => px(20.),
389 _ => px(16.),
390 };
391
392 (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
393 }
394 };
395
396 let has_indicator = matches!(
397 self.variant,
398 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
399 );
400 let num_tabs = self.children.len();
401
402 let bounds_rc = if has_indicator && num_tabs > 0 {
405 let rc: Rc<RefCell<TabIndicatorBounds>> = window
406 .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
407 Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
408 })
409 .read(cx)
410 .clone();
411 rc.borrow_mut().resize(num_tabs);
412 Some(rc)
413 } else {
414 None
415 };
416
417 let padding_x = paddings.left;
418 let indicator = self.render_indicator(&bounds_rc, padding_x, window, cx);
419 let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
420 let indicator_element = indicator.map(|(el, _)| el);
421 let indicator_ready = indicator_element.is_some();
422
423 let has_suffix_or_menu = self.suffix.is_some() || self.menu;
424 let mut item_metas: Vec<(Option<SharedString>, Option<Icon>, bool)> = Vec::new();
425 let selected_index = self.selected_index;
426 let on_click = self.on_click.clone();
427 let tabs = self.base;
428 let mut rendered_tabs = Vec::with_capacity(self.children.len());
429 let max_width = self.max_width;
430
431 for (ix, child) in self.children.into_iter().enumerate() {
432 item_metas.push((child.label.clone(), child.icon.clone(), child.disabled));
433 let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
434 let mut tab = child
435 .ix(ix)
436 .tab_bar_prefix(tab_bar_prefix)
437 .max_width(max_width)
438 .with_variant(self.variant)
439 .with_size(self.size);
440 tab.indicator_active = has_indicator;
441 tab.indicator_ready = indicator_ready;
442 tab.indicator_epoch = indicator_epoch;
443 let tab = tab
444 .when_some(selected_index, |tab, selected_index| {
445 tab.selected(selected_index == ix)
446 })
447 .when_some(self.on_click.clone(), move |tab, on_click| {
448 tab.on_click(move |_, window, cx| on_click(&ix, window, cx))
449 });
450
451 rendered_tabs.push(if let Some(ref rc) = bounds_rc {
452 let rc = rc.clone();
453 div()
454 .flex_shrink_0()
455 .on_prepaint(move |bounds, _, _| {
456 if let Some(slot) = rc.borrow_mut().tabs.get_mut(ix) {
457 *slot = bounds;
458 }
459 })
460 .child(tab)
461 .into_any_element()
462 } else {
463 tab.into_any_element()
464 });
465 }
466
467 tabs.group("tab-bar")
468 .relative()
469 .flex()
470 .items_center()
471 .bg(bg)
472 .text_color(cx.theme().tab_foreground)
473 .when(
474 self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
475 |this| {
476 this.child(
477 div()
478 .id("border-b")
479 .absolute()
480 .left_0()
481 .bottom_0()
482 .size_full()
483 .border_b_1()
484 .border_color(cx.theme().border),
485 )
486 },
487 )
488 .rounded(self.variant.tab_bar_radius(self.size, cx))
489 .paddings(paddings)
490 .refine_style(&self.style)
491 .when_some(self.prefix, |this, prefix| this.child(prefix))
492 .child(
493 h_flex()
494 .id("tabs")
495 .flex_1()
496 .mx(-padding_x)
497 .px(padding_x)
498 .overflow_x_hidden()
499 .child(
500 h_flex()
501 .id("tabs-inner")
502 .mx(-padding_x)
503 .px(padding_x)
504 .relative()
505 .gap(gap)
506 .overflow_x_scroll()
507 .lock_scroll_axis()
508 .when_some(self.scroll_handle, |this, scroll_handle| {
509 this.track_scroll(&scroll_handle)
510 })
511 .when_some(bounds_rc.clone(), |this, rc| {
512 this.on_prepaint(move |bounds, _, _| {
513 rc.borrow_mut().container = bounds;
514 })
515 })
516 .when_some(indicator_element, |this, ind| this.child(ind))
517 .children(rendered_tabs)
518 .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
519 ),
520 )
521 .when(self.menu, |this| {
522 this.child(
523 Button::new("more")
524 .xsmall()
525 .ghost()
526 .dropdown_caret(true)
527 .dropdown_menu(move |mut this, _, _| {
528 this = this.scrollable(true);
529 for (ix, (label, icon, disabled)) in item_metas.iter().enumerate() {
530 let base = if let Some(label) = label.clone() {
531 PopupMenuItem::new(label)
532 } else if let Some(icon) = icon.clone() {
533 PopupMenuItem::element(move |_, _| icon.clone())
534 } else {
535 PopupMenuItem::new(t!("Dock.Unnamed"))
536 };
537 this = this.item(
538 base.checked(selected_index == Some(ix))
539 .disabled(*disabled)
540 .when_some(on_click.clone(), |this, on_click| {
541 this.on_click(move |_, window, cx| {
542 on_click(&ix, window, cx)
543 })
544 }),
545 );
546 }
547
548 this
549 })
550 .anchor(Anchor::TopRight),
551 )
552 })
553 .when_some(self.suffix, |this, suffix| this.child(suffix))
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use std::{cell::Cell, rc::Rc};
560
561 use gpui::{Context, Modifiers, Render, TestAppContext};
562
563 use super::*;
564
565 struct Harness {
566 group_handler: bool,
567 disabled: bool,
568 child_clicks: Rc<Cell<usize>>,
569 group_clicks: Rc<Cell<usize>>,
570 }
571
572 impl Render for Harness {
573 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
574 let child_clicks = self.child_clicks.clone();
575 let group_clicks = self.group_clicks.clone();
576 TabBar::new("tabs")
577 .w(px(240.))
578 .child(
579 Tab::new()
580 .debug_selector(|| "first-tab".into())
581 .disabled(self.disabled)
582 .label("First")
583 .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
584 )
585 .when(self.group_handler, |tabs| {
586 tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
587 })
588 }
589 }
590
591 fn harness(
592 cx: &mut TestAppContext,
593 group_handler: bool,
594 disabled: bool,
595 ) -> (
596 &mut gpui::VisualTestContext,
597 Rc<Cell<usize>>,
598 Rc<Cell<usize>>,
599 ) {
600 cx.update(crate::theme::init);
601 let child_clicks = Rc::new(Cell::new(0));
602 let group_clicks = Rc::new(Cell::new(0));
603 let (_, cx) = cx.add_window_view({
604 let child_clicks = child_clicks.clone();
605 let group_clicks = group_clicks.clone();
606 move |_, _| Harness {
607 group_handler,
608 disabled,
609 child_clicks,
610 group_clicks,
611 }
612 });
613 cx.update(|window, cx| window.draw(cx).clear(cx));
614 (cx, child_clicks, group_clicks)
615 }
616
617 #[gpui::test]
618 fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
619 let (cx, child_clicks, group_clicks) = harness(cx, true, false);
620 let position = cx.debug_bounds("first-tab").unwrap().center();
621 cx.simulate_click(position, Modifiers::default());
622 assert_eq!(child_clicks.get(), 0);
623 assert_eq!(group_clicks.get(), 1);
624 }
625
626 #[gpui::test]
627 fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
628 let (cx, child_clicks, group_clicks) = harness(cx, false, false);
629 let position = cx.debug_bounds("first-tab").unwrap().center();
630 cx.simulate_click(position, Modifiers::default());
631 assert_eq!(child_clicks.get(), 1);
632 assert_eq!(group_clicks.get(), 0);
633 }
634
635 #[gpui::test]
636 fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
637 let (cx, child_clicks, group_clicks) = harness(cx, true, true);
638 let position = cx.debug_bounds("first-tab").unwrap().center();
639 cx.simulate_click(position, Modifiers::default());
640 assert_eq!(child_clicks.get(), 0);
641 assert_eq!(group_clicks.get(), 0);
642 }
643
644 struct ContentHarness;
645
646 impl Render for ContentHarness {
647 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
648 TabBar::new("content-tabs").w(px(320.)).child(
649 Tab::new()
650 .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
651 .child(div().debug_selector(|| "tab-child".into()).child("Content"))
652 .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
653 )
654 }
655 }
656
657 #[gpui::test]
658 fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
659 cx.update(crate::theme::init);
660 let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
661 cx.update(|window, cx| window.draw(cx).clear(cx));
662
663 let prefix = cx.debug_bounds("tab-prefix").unwrap();
664 let child = cx.debug_bounds("tab-child").unwrap();
665 let suffix = cx.debug_bounds("tab-suffix").unwrap();
666 assert!(prefix.origin.x < child.origin.x);
667 assert!(child.origin.x < suffix.origin.x);
668 assert!(prefix.size.width > px(0.));
669 assert!(child.size.width > px(0.));
670 assert!(suffix.size.width > px(0.));
671 }
672}