1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4 Anchor, AnyElement, App, Background, Bounds, ClickEvent, Edges, ElementId, Entity, EntityId,
5 InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
6 StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, point,
7 prelude::FluentBuilder as _, px,
8};
9use gpui_base::spring;
10use rust_i18n::t;
11use smallvec::SmallVec;
12
13use super::{Tab, TabVariant};
14use crate::button::{Button, ButtonVariants as _};
15use crate::menu::{DropdownMenu as _, PopupMenuItem};
16use crate::{
17 ActiveTheme, ElementExt, Icon, InteractiveElementExt as _, Selectable, Sizable, Size,
18 StyledExt, h_flex, styled::raised_shadow,
19};
20
21struct TabIndicatorBounds {
22 container: Bounds<Pixels>,
23 tabs: Vec<Bounds<Pixels>>,
24}
25
26impl TabIndicatorBounds {
27 fn new(num_tabs: usize) -> Self {
28 Self {
29 container: Bounds::default(),
30 tabs: vec![Bounds::default(); num_tabs],
31 }
32 }
33
34 fn resize(&mut self, num_tabs: usize) {
35 self.tabs.resize(num_tabs, Bounds::default());
36 }
37}
38
39#[derive(IntoElement)]
41pub struct TabBar {
42 id: ElementId,
43 base: gpui_base::Tabs,
44 style: StyleRefinement,
45 scroll_handle: Option<ScrollHandle>,
46 prefix: Option<AnyElement>,
47 suffix: Option<AnyElement>,
48 children: SmallVec<[Tab; 2]>,
49 last_empty_space: AnyElement,
50 selected_index: Option<usize>,
51 variant: TabVariant,
52 size: Size,
53 menu: bool,
54 max_width: Option<Pixels>,
55 on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
56}
57
58impl TabBar {
59 pub fn new(id: impl Into<ElementId>) -> Self {
61 let id = id.into();
62 Self {
63 id: id.clone(),
64 base: gpui_base::Tabs::new(id).px(px(-1.)),
65 style: StyleRefinement::default(),
66 children: SmallVec::new(),
67 scroll_handle: None,
68 prefix: None,
69 suffix: None,
70 variant: TabVariant::default(),
71 size: Size::default(),
72 last_empty_space: div().w_3().into_any_element(),
73 selected_index: None,
74 on_click: None,
75 menu: false,
76 max_width: None,
77 }
78 }
79
80 pub fn with_variant(mut self, variant: TabVariant) -> Self {
82 self.variant = variant;
83 self
84 }
85
86 pub fn pill(mut self) -> Self {
88 self.variant = TabVariant::Pill;
89 self
90 }
91
92 pub fn outline(mut self) -> Self {
94 self.variant = TabVariant::Outline;
95 self
96 }
97
98 pub fn segmented(mut self) -> Self {
100 self.variant = TabVariant::Segmented;
101 self
102 }
103
104 pub fn underline(mut self) -> Self {
106 self.variant = TabVariant::Underline;
107 self
108 }
109
110 pub fn menu(mut self, menu: bool) -> Self {
112 self.menu = menu;
113 self
114 }
115
116 pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
120 self.max_width = Some(width.into());
121 self
122 }
123
124 pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
130 self.scroll_handle = Some(scroll_handle.clone());
131 self
132 }
133
134 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
136 self.prefix = Some(prefix.into_any_element());
137 self
138 }
139
140 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
142 self.suffix = Some(suffix.into_any_element());
143 self
144 }
145
146 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
148 self.children.extend(children.into_iter().map(Into::into));
149 self
150 }
151
152 pub fn child(mut self, child: impl Into<Tab>) -> Self {
154 self.children.push(child.into());
155 self
156 }
157
158 pub fn selected_index(mut self, index: usize) -> Self {
160 self.selected_index = Some(index);
161 self
162 }
163
164 pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
166 self.last_empty_space = last_empty_space.into_any_element();
167 self
168 }
169
170 pub fn on_click<F>(mut self, on_click: F) -> Self
174 where
175 F: Fn(&usize, &mut Window, &mut App) + 'static,
176 {
177 self.on_click = Some(Rc::new(on_click));
178 self
179 }
180
181 fn render_indicator(
188 &self,
189 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
190 window: &mut Window,
191 cx: &mut App,
192 ) -> Option<(AnyElement, u64)> {
193 let has_indicator = matches!(
194 self.variant,
195 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
196 );
197 let num_tabs = self.children.len();
198 let selected_ix = self.selected_index.unwrap_or(usize::MAX);
199
200 if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
201 return None;
202 }
203
204 let prev_key = format!("{}-tab-prev", self.id);
205 let anim_key = format!("{}-tab-anim", self.id);
206 let init_key = format!("{}-tab-init", self.id);
207
208 let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
209 let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
211 let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
212
213 if !*initialized.read(cx) {
215 initialized.update(cx, |v, _| *v = true);
216 }
217
218 self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
219
220 let (to_left, to_width, epoch) = *anim_params.read(cx);
221 if to_width <= px(0.) {
222 return None;
223 }
224
225 let indicator_key = format!("{}-tab-indicator", self.id);
229 let left = spring(
230 (indicator_key.clone(), "left"),
231 to_left,
232 cx.theme().motion_tokens().spring_move,
233 window,
234 cx,
235 );
236 let width = spring(
237 (indicator_key, "width"),
238 to_width,
239 cx.theme().motion_tokens().spring_move,
240 window,
241 cx,
242 );
243
244 let variant = self.variant;
245 let size = self.size;
246 let inner_height = variant.inner_height(size);
247 let inner_radius = variant.inner_radius(size, cx);
248
249 let indicator = div()
250 .absolute()
251 .top_0()
252 .bottom_0()
253 .left(left)
254 .w(width)
255 .map(|el| match variant {
256 TabVariant::Segmented => el.flex().items_center().child(
257 div()
258 .w_full()
259 .h(inner_height)
260 .bg(cx.theme().tokens.background)
261 .rounded(inner_radius)
262 .shadow(raised_shadow()),
263 ),
264 TabVariant::Pill => el.flex().items_center().child(
265 div()
266 .size_full()
267 .bg(cx.theme().tokens.primary)
268 .rounded(cx.theme().radius_full()),
269 ),
270 TabVariant::Underline => el.child(
271 div()
272 .absolute()
273 .left_0()
274 .right_0()
275 .bottom_0()
276 .h(px(2.))
277 .bg(cx.theme().tokens.primary),
278 ),
279 _ => el,
280 });
281
282 Some((indicator.into_any_element(), epoch))
283 }
284
285 fn update_anim_params(
287 &self,
288 selected_ix: usize,
289 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
290 prev_selected: &gpui::Entity<usize>,
291 anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
292 cx: &mut App,
293 ) {
294 let rc = match bounds_rc {
295 Some(rc) => rc,
296 None => return,
297 };
298
299 let prev_ix = *prev_selected.read(cx);
300 let bounds = rc.borrow();
301 let container = bounds.container;
302
303 if container.size.width == px(0.) {
304 if prev_ix != selected_ix {
305 prev_selected.update(cx, |v, _| *v = selected_ix);
306 }
307 return;
308 }
309
310 let first_tab_origin = bounds
313 .tabs
314 .first()
315 .map(|tab| tab.origin.x)
316 .unwrap_or(container.origin.x);
317
318 if prev_ix != selected_ix {
319 if let Some(to_b) = bounds.tabs.get(selected_ix) {
320 let left = to_b.origin.x - first_tab_origin;
321 let width = to_b.size.width;
322 let epoch = anim_params.read(cx).2;
325 let epoch = match bounds.tabs.get(prev_ix) {
326 Some(_) => epoch + 1,
327 None => epoch,
328 };
329 anim_params.update(cx, |v, _| *v = (left, width, epoch));
330 }
331 drop(bounds);
332 prev_selected.update(cx, |v, _| *v = selected_ix);
333 return;
334 }
335
336 if let Some(to_b) = bounds.tabs.get(selected_ix) {
337 let left = to_b.origin.x - first_tab_origin;
338 let width = to_b.size.width;
339 let (to_left, to_width, epoch) = *anim_params.read(cx);
340
341 if left != to_left || width != to_width {
342 anim_params.update(cx, |v, _| *v = (left, width, epoch));
343 }
344 }
345 }
346}
347
348impl Styled for TabBar {
349 fn style(&mut self) -> &mut StyleRefinement {
350 &mut self.style
351 }
352}
353
354impl Sizable for TabBar {
355 fn with_size(mut self, size: impl Into<Size>) -> Self {
356 self.size = size.into();
357 self
358 }
359}
360
361impl RenderOnce for TabBar {
362 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
363 let default_gap = match self.size {
364 Size::Small | Size::XSmall => px(8.),
365 Size::Large => px(16.),
366 _ => px(12.),
367 };
368 let (bg, paddings, gap): (Background, _, _) = match self.variant {
369 TabVariant::Tab => {
370 let padding = Edges::all(px(0.));
371 (cx.theme().tokens.tab_bar.into(), padding, px(0.))
372 }
373 TabVariant::Outline => {
374 let padding = Edges::all(px(0.));
375 (cx.theme().transparent.into(), padding, default_gap)
376 }
377 TabVariant::Pill => {
378 let padding = Edges::all(px(0.));
379 (cx.theme().transparent.into(), padding, px(4.))
380 }
381 TabVariant::Segmented => {
382 let padding_x = match self.size {
383 Size::XSmall => px(2.),
384 Size::Small => px(3.),
385 _ => px(4.),
386 };
387 let padding = Edges {
388 left: padding_x,
389 right: padding_x,
390 ..Default::default()
391 };
392
393 (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
394 }
395 TabVariant::Underline => {
396 let gap = match self.size {
398 Size::XSmall => px(10.),
399 Size::Small => px(12.),
400 Size::Large => px(20.),
401 _ => px(16.),
402 };
403
404 (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
405 }
406 };
407
408 let has_indicator = matches!(
409 self.variant,
410 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
411 );
412 let num_tabs = self.children.len();
413
414 let bounds_rc = if has_indicator && num_tabs > 0 {
417 let rc: Rc<RefCell<TabIndicatorBounds>> = window
418 .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
419 Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
420 })
421 .read(cx)
422 .clone();
423 rc.borrow_mut().resize(num_tabs);
424 Some(rc)
425 } else {
426 None
427 };
428
429 let scroll_handle = self.scroll_handle.clone().unwrap_or_else(|| {
431 window
432 .use_keyed_state(format!("{}-scroll", self.id), cx, |_, _| {
433 ScrollHandle::new()
434 })
435 .read(cx)
436 .clone()
437 });
438 let reveal = TabReveal {
439 spring_id: format!("{}-reveal", self.id).into(),
440 target: window.use_keyed_state(format!("{}-reveal", self.id), cx, |_, _| None),
441 scroll_handle: scroll_handle.clone(),
442 view: window.current_view(),
443 peek: match self.size {
444 Size::XSmall | Size::Small => px(24.),
445 Size::Large => px(40.),
446 _ => px(32.),
447 },
448 num_tabs,
449 };
450 reveal.advance(window, cx);
451
452 let padding_x = paddings.left;
453 let indicator = self.render_indicator(&bounds_rc, window, cx);
454 let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
455 let mut indicator_element = indicator.map(|(el, _)| el);
456 let indicator_ready = indicator_element.is_some();
457
458 let has_suffix_or_menu = self.suffix.is_some() || self.menu;
459 let mut item_metas: Vec<(Option<SharedString>, Option<Icon>, bool)> = Vec::new();
460 let selected_index = self.selected_index;
461 let on_click = self.on_click.clone();
462 let tabs = self.base;
463 let mut rendered_tabs = Vec::with_capacity(self.children.len());
464 let max_width = self.max_width;
465
466 for (ix, child) in self.children.into_iter().enumerate() {
467 item_metas.push((child.label.clone(), child.icon.clone(), child.disabled));
468 let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
469 let mut tab = child
470 .ix(ix)
471 .tab_bar_prefix(tab_bar_prefix)
472 .max_width(max_width)
473 .with_variant(self.variant)
474 .with_size(self.size);
475 tab.indicator_active = has_indicator;
476 tab.indicator_ready = indicator_ready;
477 tab.indicator_epoch = indicator_epoch;
478 let mut tab = tab
479 .when_some(selected_index, |tab, selected_index| {
480 tab.selected(selected_index == ix)
481 })
482 .map(|mut tab| {
483 let on_click: Option<TabClickHandler> = match self.on_click.clone() {
485 Some(on_click) => {
486 Some(Rc::new(move |_, window, cx| on_click(&ix, window, cx)))
487 }
488 None => tab.on_click.take(),
489 };
490 tab.on_click = on_click.map(|on_click| {
491 let reveal = reveal.clone();
492 Rc::new(
493 move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
494 reveal.reveal(ix, cx);
495 on_click(event, window, cx)
496 },
497 ) as TabClickHandler
498 });
499 tab
500 });
501 let flex_grow = tab.style().flex_grow;
504 let flex_basis = tab.style().flex_basis;
505
506 rendered_tabs.push(if let Some(ref rc) = bounds_rc {
507 let rc = rc.clone();
508 div()
512 .flex_shrink_0()
513 .map(|mut this| {
514 this.style().flex_grow = flex_grow;
515 this.style().flex_basis = flex_basis;
516 this
517 })
518 .on_prepaint(move |bounds, _, _| {
519 if let Some(slot) = rc.borrow_mut().tabs.get_mut(ix) {
520 *slot = bounds;
521 }
522 })
523 .relative()
524 .when(ix == 0, |this| {
525 this.when_some(indicator_element.take(), |this, indicator| {
526 this.child(indicator)
527 })
528 })
529 .child(tab)
530 .into_any_element()
531 } else {
532 tab.into_any_element()
533 });
534 }
535
536 tabs.group("tab-bar")
537 .relative()
538 .flex()
539 .items_center()
540 .bg(bg)
541 .text_color(cx.theme().tab_foreground)
542 .when(
543 self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
544 |this| {
545 this.child(
546 div()
547 .id("border-b")
548 .absolute()
549 .left_0()
550 .bottom_0()
551 .size_full()
552 .border_b_1()
553 .border_color(cx.theme().border),
554 )
555 },
556 )
557 .rounded(self.variant.tab_bar_radius(self.size, cx))
558 .paddings(paddings)
559 .refine_style(&self.style)
560 .when_some(self.prefix, |this, prefix| this.child(prefix))
561 .child(
562 h_flex()
563 .id("tabs")
564 .flex_1()
565 .min_w_0()
566 .mx(-padding_x)
567 .px(padding_x)
568 .overflow_x_hidden()
569 .when_some(bounds_rc.clone(), |this, rc| {
572 this.on_prepaint(move |bounds, _, _| {
573 rc.borrow_mut().container = bounds;
574 })
575 })
576 .child(
577 h_flex()
578 .id("tabs-inner")
579 .flex_1()
582 .relative()
585 .gap(gap)
586 .overflow_x_scroll()
587 .lock_scroll_axis()
588 .track_scroll(&scroll_handle)
589 .children(rendered_tabs)
590 .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
591 ),
592 )
593 .when(self.menu, |this| {
594 this.child(
595 Button::new("more")
596 .xsmall()
597 .ghost()
598 .dropdown_caret(true)
599 .dropdown_menu(move |mut this, _, _| {
600 this = this.scrollable(true);
601 for (ix, (label, icon, disabled)) in item_metas.iter().enumerate() {
602 let base = if let Some(label) = label.clone() {
603 PopupMenuItem::new(label)
604 } else if let Some(icon) = icon.clone() {
605 PopupMenuItem::element(move |_, _| icon.clone())
606 } else {
607 PopupMenuItem::new(t!("Dock.Unnamed"))
608 };
609 this = this.item(
610 base.checked(selected_index == Some(ix))
611 .disabled(*disabled)
612 .when_some(on_click.clone(), |this, on_click| {
613 let reveal = reveal.clone();
614 this.on_click(move |_, window, cx| {
615 reveal.reveal(ix, cx);
616 on_click(&ix, window, cx)
617 })
618 }),
619 );
620 }
621
622 this
623 })
624 .anchor(Anchor::TopRight),
625 )
626 })
627 .when_some(self.suffix, |this, suffix| this.child(suffix))
628 }
629}
630
631type TabClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
632
633#[derive(Clone)]
638struct TabReveal {
639 spring_id: SharedString,
640 target: Entity<Option<RevealTarget>>,
642 scroll_handle: ScrollHandle,
643 view: EntityId,
644 peek: Pixels,
646 num_tabs: usize,
647}
648
649#[derive(Clone, Copy)]
650struct RevealTarget {
651 to: Pixels,
652 last: Option<Pixels>,
654}
655
656impl TabReveal {
657 fn reveal(&self, ix: usize, cx: &mut App) {
658 let Some(to) = self.offset_revealing(ix) else {
659 return;
660 };
661 let running = self.target.read(cx).is_some();
662 if !running && to == self.scroll_handle.offset().x {
663 return;
664 }
665 self.target.update(cx, |target, _| match target {
666 Some(target) => target.to = to,
668 None => *target = Some(RevealTarget { to, last: None }),
669 });
670 cx.notify(self.view);
671 }
672
673 fn offset_revealing(&self, ix: usize) -> Option<Pixels> {
676 let handle = &self.scroll_handle;
677 let viewport = handle.bounds();
678 let tab = handle.bounds_for_item(ix)?;
679 let max = handle.max_offset().x;
680 let want_left = match ix.checked_sub(1).and_then(|ix| handle.bounds_for_item(ix)) {
683 Some(prev) => (prev.right() - self.peek).max(prev.left()),
684 None => viewport.left(),
685 };
686 let want_right = match (ix + 1 < self.num_tabs)
687 .then(|| handle.bounds_for_item(ix + 1))
688 .flatten()
689 {
690 Some(next) => (next.left() + self.peek).min(next.right()),
691 None => viewport.right() + max,
692 };
693
694 let mut offset = handle.offset().x;
695 let show_left = |offset: &mut Pixels, left: Pixels| {
696 if left < viewport.left() - *offset {
697 *offset = viewport.left() - left;
698 }
699 };
700 let show_right = |offset: &mut Pixels, right: Pixels| {
701 if right > viewport.right() - *offset {
702 *offset = viewport.right() - right;
703 }
704 };
705 if tab.center().x + offset > viewport.center().x {
708 show_left(&mut offset, want_left);
709 show_right(&mut offset, want_right);
710 } else {
711 show_right(&mut offset, want_right);
712 show_left(&mut offset, want_left);
713 }
714 show_right(&mut offset, tab.right());
715 show_left(&mut offset, tab.left());
716 Some(offset.min(px(0.)).max(-max))
717 }
718
719 fn advance(&self, window: &mut Window, cx: &mut App) {
721 let Some(target) = *self.target.read(cx) else {
722 return;
723 };
724 let offset = self.scroll_handle.offset();
725 if target.last.is_some_and(|last| last != offset.x) {
727 self.target.update(cx, |target, _| *target = None);
728 return;
729 }
730
731 let policy = cx.theme().motion_tokens().spring_move;
732 if target.last.is_none() {
733 spring(
735 self.spring_id.clone(),
736 offset.x,
737 policy.with_travel(false),
738 window,
739 cx,
740 );
741 }
742 let x = spring(self.spring_id.clone(), target.to, policy, window, cx);
743 self.scroll_handle.set_offset(point(x, offset.y));
744 self.target.update(cx, |state, _| {
745 *state = (x != target.to).then_some(RevealTarget {
746 last: Some(x),
747 ..target
748 })
749 });
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use std::{cell::Cell, rc::Rc};
756
757 use gpui::{Context, Modifiers, Render, TestAppContext};
758
759 use super::*;
760
761 struct Harness {
762 group_handler: bool,
763 disabled: bool,
764 child_clicks: Rc<Cell<usize>>,
765 group_clicks: Rc<Cell<usize>>,
766 }
767
768 impl Render for Harness {
769 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
770 let child_clicks = self.child_clicks.clone();
771 let group_clicks = self.group_clicks.clone();
772 TabBar::new("tabs")
773 .w(px(240.))
774 .child(
775 Tab::new()
776 .debug_selector(|| "first-tab".into())
777 .disabled(self.disabled)
778 .label("First")
779 .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
780 )
781 .when(self.group_handler, |tabs| {
782 tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
783 })
784 }
785 }
786
787 fn harness(
788 cx: &mut TestAppContext,
789 group_handler: bool,
790 disabled: bool,
791 ) -> (
792 &mut gpui::VisualTestContext,
793 Rc<Cell<usize>>,
794 Rc<Cell<usize>>,
795 ) {
796 cx.update(crate::theme::init);
797 let child_clicks = Rc::new(Cell::new(0));
798 let group_clicks = Rc::new(Cell::new(0));
799 let (_, cx) = cx.add_window_view({
800 let child_clicks = child_clicks.clone();
801 let group_clicks = group_clicks.clone();
802 move |_, _| Harness {
803 group_handler,
804 disabled,
805 child_clicks,
806 group_clicks,
807 }
808 });
809 cx.update(|window, cx| window.draw(cx).clear(cx));
810 (cx, child_clicks, group_clicks)
811 }
812
813 #[gpui::test]
814 fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
815 let (cx, child_clicks, group_clicks) = harness(cx, true, false);
816 let position = cx.debug_bounds("first-tab").unwrap().center();
817 cx.simulate_click(position, Modifiers::default());
818 assert_eq!(child_clicks.get(), 0);
819 assert_eq!(group_clicks.get(), 1);
820 }
821
822 #[gpui::test]
823 fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
824 let (cx, child_clicks, group_clicks) = harness(cx, false, false);
825 let position = cx.debug_bounds("first-tab").unwrap().center();
826 cx.simulate_click(position, Modifiers::default());
827 assert_eq!(child_clicks.get(), 1);
828 assert_eq!(group_clicks.get(), 0);
829 }
830
831 #[gpui::test]
832 fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
833 let (cx, child_clicks, group_clicks) = harness(cx, true, true);
834 let position = cx.debug_bounds("first-tab").unwrap().center();
835 cx.simulate_click(position, Modifiers::default());
836 assert_eq!(child_clicks.get(), 0);
837 assert_eq!(group_clicks.get(), 0);
838 }
839
840 struct ContentHarness;
841
842 impl Render for ContentHarness {
843 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
844 TabBar::new("content-tabs").w(px(320.)).child(
845 Tab::new()
846 .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
847 .child(div().debug_selector(|| "tab-child".into()).child("Content"))
848 .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
849 )
850 }
851 }
852
853 #[gpui::test]
854 fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
855 cx.update(crate::theme::init);
856 let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
857 cx.update(|window, cx| window.draw(cx).clear(cx));
858
859 let prefix = cx.debug_bounds("tab-prefix").unwrap();
860 let child = cx.debug_bounds("tab-child").unwrap();
861 let suffix = cx.debug_bounds("tab-suffix").unwrap();
862 assert!(prefix.origin.x < child.origin.x);
863 assert!(child.origin.x < suffix.origin.x);
864 assert!(prefix.size.width > px(0.));
865 assert!(child.size.width > px(0.));
866 assert!(suffix.size.width > px(0.));
867 }
868
869 struct ScrollHarness {
870 scroll_handle: ScrollHandle,
871 }
872
873 struct DynamicScrollHarness {
874 scroll_handle: ScrollHandle,
875 menu: bool,
876 size: Size,
877 tabs: usize,
878 selected_index: usize,
879 }
880
881 struct ManualScrollHarness {
882 scroll_handle: ScrollHandle,
883 tabs: usize,
884 selected_index: usize,
885 label: &'static str,
886 top: Pixels,
887 }
888
889 impl ScrollHarness {
890 fn tabs() -> impl Iterator<Item = Tab> {
891 (0..5).map(|ix| {
892 Tab::new()
893 .w(px(60.))
894 .label(format!("Tab {ix}"))
895 .debug_selector(move || format!("tab-{ix}"))
896 })
897 }
898 }
899
900 impl Render for ScrollHarness {
901 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
902 div().w(px(100.)).child(
903 TabBar::new("scrolling-tabs")
904 .w_full()
905 .segmented()
906 .menu(true)
907 .track_scroll(&self.scroll_handle)
908 .selected_index(4)
909 .children(Self::tabs()),
910 )
911 }
912 }
913
914 impl DynamicScrollHarness {
915 fn tabs(&self) -> impl Iterator<Item = Tab> {
916 (0..self.tabs).map(|ix| {
917 Tab::new()
918 .w(px(60.))
919 .label(format!("Tab {ix}"))
920 .debug_selector(move || format!("dynamic-tab-{ix}"))
921 })
922 }
923 }
924
925 impl Render for DynamicScrollHarness {
926 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
927 div()
928 .w(px(100.))
929 .debug_selector(|| "dynamic-bar".into())
930 .child(
931 TabBar::new("dynamic-scrolling-tabs")
932 .with_size(self.size)
933 .w_full()
934 .segmented()
935 .menu(self.menu)
936 .track_scroll(&self.scroll_handle)
937 .selected_index(self.selected_index)
938 .children(self.tabs()),
939 )
940 }
941 }
942
943 impl ManualScrollHarness {
944 fn tabs(&self) -> impl Iterator<Item = Tab> {
945 let width = if self.label == "old" {
946 px(60.)
947 } else {
948 px(120.)
949 };
950 let label = self.label;
951 (0..self.tabs).map(move |ix| {
952 Tab::new()
953 .w(width)
954 .label(format!("Tab {ix} {label}"))
955 .debug_selector(move || format!("manual-tab-{ix}"))
956 })
957 }
958 }
959
960 impl Render for ManualScrollHarness {
961 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
962 div().w(px(160.)).h(px(40.)).child(
963 div().relative().top(self.top).w_full().child(
964 TabBar::new("manual-scrolling-tabs")
965 .w_full()
966 .segmented()
967 .menu(true)
968 .track_scroll(&self.scroll_handle)
969 .selected_index(self.selected_index)
970 .children(self.tabs()),
971 ),
972 )
973 }
974 }
975
976 fn draw(cx: &mut gpui::VisualTestContext) {
977 cx.run_until_parked();
978 cx.update(|window, cx| window.draw(cx).clear(cx));
979 }
980
981 #[gpui::test]
982 fn scrolling_to_a_tab_uses_logical_tab_indices(cx: &mut TestAppContext) {
983 cx.update(crate::theme::init);
984 let scroll_handle = ScrollHandle::new();
985 let (_, cx) = cx.add_window_view({
986 let scroll_handle = scroll_handle.clone();
987 move |_, _| ScrollHarness { scroll_handle }
988 });
989
990 draw(cx);
991 draw(cx);
992 assert_eq!(scroll_handle.offset().x, px(0.));
993 scroll_handle.scroll_to_item(4);
994 draw(cx);
995 draw(cx);
996
997 let viewport = scroll_handle.bounds();
998 let last_tab = cx.debug_bounds("tab-4").unwrap();
999 assert!(
1000 last_tab.left() >= viewport.left(),
1001 "last tab {last_tab:?} is left of viewport {viewport:?}, offset {:?}",
1002 scroll_handle.offset()
1003 );
1004 assert!(
1005 last_tab.right() <= viewport.right(),
1006 "last tab {last_tab:?} is right of viewport {viewport:?}, offset {:?}",
1007 scroll_handle.offset()
1008 );
1009 assert_eq!(scroll_handle.children_count(), 6);
1010 }
1011
1012 #[gpui::test]
1013 fn scrolling_to_a_new_tab_preserves_the_explicit_target(cx: &mut TestAppContext) {
1014 cx.update(crate::theme::init);
1015 let scroll_handle = ScrollHandle::new();
1016 let (view, cx) = cx.add_window_view({
1017 let scroll_handle = scroll_handle.clone();
1018 move |_, _| DynamicScrollHarness {
1019 scroll_handle,
1020 menu: true,
1021 size: Size::default(),
1022 tabs: 4,
1023 selected_index: 3,
1024 }
1025 });
1026
1027 draw(cx);
1028 draw(cx);
1029
1030 view.update(cx, |view, cx| {
1031 view.tabs = 5;
1032 view.selected_index = 4;
1033 view.scroll_handle.scroll_to_item(4);
1034 cx.notify();
1035 });
1036 draw(cx);
1037
1038 let viewport = scroll_handle.bounds();
1039 let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
1040 assert!(last_tab.left() >= viewport.left());
1041 assert!(last_tab.right() <= viewport.right());
1042 assert_eq!(scroll_handle.children_count(), 6);
1043 }
1044
1045 #[gpui::test]
1046 fn scrolling_to_a_new_tab_preserves_bar_padding(cx: &mut TestAppContext) {
1047 cx.update(crate::theme::init);
1048 for (size, padding) in [
1049 (Size::XSmall, px(2.)),
1050 (Size::Small, px(3.)),
1051 (Size::Medium, px(4.)),
1052 (Size::Large, px(4.)),
1053 ] {
1054 let scroll_handle = ScrollHandle::new();
1055 let (view, cx) = cx.add_window_view({
1056 let scroll_handle = scroll_handle.clone();
1057 move |_, _| DynamicScrollHarness {
1058 scroll_handle,
1059 menu: false,
1060 size,
1061 tabs: 4,
1062 selected_index: 0,
1063 }
1064 });
1065 draw(cx);
1066 draw(cx);
1067 view.update(cx, |view, cx| {
1068 view.tabs = 5;
1069 view.scroll_handle.scroll_to_item(4);
1070 cx.notify();
1071 });
1072 draw(cx);
1073 draw(cx);
1074 let bar = cx.debug_bounds("dynamic-bar").unwrap();
1075 let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
1076 assert_eq!(
1077 bar.right() - last_tab.right(),
1078 padding,
1079 "right padding for {size:?}"
1080 );
1081 scroll_handle.scroll_to_item(0);
1082 draw(cx);
1083 draw(cx);
1084 let first_tab = cx.debug_bounds("dynamic-tab-0").unwrap();
1085 assert_eq!(
1086 first_tab.left() - bar.left(),
1087 padding,
1088 "left padding for {size:?}"
1089 );
1090 assert_eq!(scroll_handle.children_count(), 5);
1091 }
1092 }
1093
1094 #[gpui::test]
1095 fn closing_an_unselected_trailing_tab_preserves_manual_scrolling(cx: &mut TestAppContext) {
1096 cx.update(crate::theme::init);
1097 let scroll_handle = ScrollHandle::new();
1098 let (view, cx) = cx.add_window_view({
1099 let scroll_handle = scroll_handle.clone();
1100 move |_, _| ManualScrollHarness {
1101 scroll_handle,
1102 tabs: 6,
1103 selected_index: 0,
1104 label: "old",
1105 top: px(0.),
1106 }
1107 });
1108
1109 draw(cx);
1110 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1111 draw(cx);
1112 assert_eq!(scroll_handle.offset().x, px(-100.));
1113
1114 view.update(cx, |view, cx| {
1115 view.tabs = 5;
1116 cx.notify();
1117 });
1118 draw(cx);
1119
1120 assert_eq!(scroll_handle.offset().x, px(-100.));
1121 }
1122
1123 #[gpui::test]
1124 fn changing_selection_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
1125 cx.update(crate::theme::init);
1126 let scroll_handle = ScrollHandle::new();
1127 let (view, cx) = cx.add_window_view({
1128 let scroll_handle = scroll_handle.clone();
1129 move |_, _| ManualScrollHarness {
1130 scroll_handle,
1131 tabs: 6,
1132 selected_index: 0,
1133 label: "old",
1134 top: px(0.),
1135 }
1136 });
1137
1138 draw(cx);
1139 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1140 draw(cx);
1141
1142 view.update(cx, |view, cx| {
1143 view.selected_index = 5;
1144 cx.notify();
1145 });
1146 draw(cx);
1147
1148 assert_eq!(scroll_handle.offset().x, px(-100.));
1149 }
1150
1151 #[gpui::test]
1152 fn changing_tab_labels_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
1153 cx.update(crate::theme::init);
1154 let scroll_handle = ScrollHandle::new();
1155 let (view, cx) = cx.add_window_view({
1156 let scroll_handle = scroll_handle.clone();
1157 move |_, _| ManualScrollHarness {
1158 scroll_handle,
1159 tabs: 6,
1160 selected_index: 0,
1161 label: "old",
1162 top: px(0.),
1163 }
1164 });
1165
1166 draw(cx);
1167 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1168 draw(cx);
1169
1170 view.update(cx, |view, cx| {
1171 view.label = "new";
1172 cx.notify();
1173 });
1174 draw(cx);
1175
1176 assert_eq!(scroll_handle.offset().x, px(-100.));
1177 }
1178
1179 #[gpui::test]
1180 fn moving_the_tab_bar_preserves_manual_scrolling(cx: &mut TestAppContext) {
1181 cx.update(crate::theme::init);
1182 let scroll_handle = ScrollHandle::new();
1183 let (view, cx) = cx.add_window_view({
1184 let scroll_handle = scroll_handle.clone();
1185 move |_, _| ManualScrollHarness {
1186 scroll_handle,
1187 tabs: 6,
1188 selected_index: 0,
1189 label: "old",
1190 top: px(0.),
1191 }
1192 });
1193
1194 draw(cx);
1195 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1196 draw(cx);
1197 assert_eq!(scroll_handle.offset().x, px(-100.));
1198 let viewport_size = scroll_handle.bounds().size;
1199
1200 view.update(cx, |view, cx| {
1201 view.top = px(20.);
1202 cx.notify();
1203 });
1204 draw(cx);
1205
1206 assert_eq!(scroll_handle.bounds().size, viewport_size);
1207 assert_eq!(scroll_handle.offset().x, px(-100.));
1208 }
1209
1210 struct FlexHarness {
1211 variant: TabVariant,
1212 }
1213
1214 impl Render for FlexHarness {
1215 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1216 div()
1217 .w(px(200.))
1218 .debug_selector(|| "flex-bar".into())
1219 .child(
1220 TabBar::new("flex-tabs")
1221 .w_full()
1222 .with_variant(self.variant)
1223 .selected_index(0)
1224 .child(
1225 Tab::new()
1226 .flex_1()
1227 .label("A")
1228 .debug_selector(|| "flex-tab-0".into()),
1229 )
1230 .child(
1231 Tab::new()
1232 .flex_1()
1233 .label("B")
1234 .debug_selector(|| "flex-tab-1".into()),
1235 ),
1236 )
1237 }
1238 }
1239
1240 #[gpui::test]
1241 fn flex_tabs_share_the_available_width(cx: &mut TestAppContext) {
1242 cx.update(crate::theme::init);
1243 for variant in [
1244 TabVariant::Tab,
1245 TabVariant::Outline,
1246 TabVariant::Segmented,
1247 TabVariant::Pill,
1248 TabVariant::Underline,
1249 ] {
1250 let (_, cx) = cx.add_window_view(move |_, _| FlexHarness { variant });
1251 draw(cx);
1252 draw(cx);
1253
1254 let bar = cx.debug_bounds("flex-bar").unwrap();
1255 let first = cx.debug_bounds("flex-tab-0").unwrap();
1256 let second = cx.debug_bounds("flex-tab-1").unwrap();
1257 assert_eq!(first.size.width, second.size.width, "{variant:?}");
1258 assert_eq!(
1260 first.left() - bar.left(),
1261 bar.right() - second.right(),
1262 "{variant:?}"
1263 );
1264 assert!(
1265 bar.right() - second.right() <= px(4.),
1266 "{variant:?}: tabs end at {:?} but the bar ends at {:?}",
1267 second.right(),
1268 bar.right()
1269 );
1270 }
1271 }
1272
1273 struct ClickRevealHarness {
1274 group_handler: bool,
1275 scroll_handle: Option<ScrollHandle>,
1276 }
1277
1278 impl Render for ClickRevealHarness {
1279 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1280 div()
1282 .w(px(100.))
1283 .debug_selector(|| "click-bar".into())
1284 .child(
1285 TabBar::new("click-reveal-tabs")
1286 .w_full()
1287 .when_some(self.scroll_handle.as_ref(), |tabs, handle| {
1288 tabs.track_scroll(handle)
1289 })
1290 .children((0..5).map(|ix| {
1291 Tab::new()
1292 .w(px(60.))
1293 .label(format!("Tab {ix}"))
1294 .debug_selector(move || format!("click-tab-{ix}"))
1295 .on_click(|_, _, _| {})
1296 }))
1297 .when(self.group_handler, |tabs| tabs.on_click(|_, _, _| {})),
1298 )
1299 }
1300 }
1301
1302 fn click_reveal_harness(
1303 cx: &mut TestAppContext,
1304 group_handler: bool,
1305 scroll_handle: Option<ScrollHandle>,
1306 reduce_motion: bool,
1307 ) -> &mut gpui::VisualTestContext {
1308 cx.update(|cx| {
1309 crate::theme::init(cx);
1310 cx.set_reduce_motion(reduce_motion);
1311 });
1312 let (_, cx) = cx.add_window_view(move |_, _| ClickRevealHarness {
1313 group_handler,
1314 scroll_handle,
1315 });
1316 draw(cx);
1317 cx
1318 }
1319
1320 fn next_frame(cx: &mut gpui::VisualTestContext) {
1321 cx.executor()
1322 .advance_clock(std::time::Duration::from_millis(16));
1323 cx.update(|window, cx| window.simulate_next_frame(cx));
1324 draw(cx);
1325 }
1326
1327 #[gpui::test]
1328 fn clicking_a_clipped_tab_reveals_it_and_part_of_the_next(cx: &mut TestAppContext) {
1329 for group_handler in [false, true] {
1330 let cx = click_reveal_harness(cx, group_handler, None, true);
1331 let bar = cx.debug_bounds("click-bar").unwrap();
1332 let tab = cx.debug_bounds("click-tab-1").unwrap();
1333 assert!(tab.right() > bar.right(), "tab 1 starts clipped");
1334
1335 cx.simulate_click(tab.center(), Modifiers::default());
1336 draw(cx);
1337
1338 let tab = cx.debug_bounds("click-tab-1").unwrap();
1339 let next = cx.debug_bounds("click-tab-2").unwrap();
1340 assert!(tab.left() >= bar.left() && tab.right() <= bar.right());
1341 assert_eq!(bar.right() - next.left(), px(32.), "group: {group_handler}");
1342 }
1343 }
1344
1345 #[gpui::test]
1346 fn clicking_the_last_tab_reveals_the_end_of_the_bar(cx: &mut TestAppContext) {
1347 let scroll_handle = ScrollHandle::new();
1348 let cx = click_reveal_harness(cx, true, Some(scroll_handle.clone()), true);
1349 scroll_handle.set_offset(gpui::point(px(-160.), px(0.)));
1350 draw(cx);
1351
1352 let tab = cx.debug_bounds("click-tab-4").unwrap();
1353 cx.simulate_click(
1354 tab.origin + gpui::point(px(10.), px(10.)),
1355 Modifiers::default(),
1356 );
1357 draw(cx);
1358
1359 assert_eq!(scroll_handle.offset().x, -scroll_handle.max_offset().x);
1360 }
1361
1362 #[gpui::test]
1363 fn reveal_animates_and_yields_to_manual_scrolling(cx: &mut TestAppContext) {
1364 let scroll_handle = ScrollHandle::new();
1365 let cx = click_reveal_harness(cx, true, Some(scroll_handle.clone()), false);
1366
1367 let tab = cx.debug_bounds("click-tab-1").unwrap();
1368 cx.simulate_click(tab.center(), Modifiers::default());
1369 draw(cx);
1370 next_frame(cx);
1371 let moving = scroll_handle.offset().x;
1372 assert!(
1373 moving < px(0.) && moving > px(-52.),
1374 "mid-flight at {moving:?}"
1375 );
1376
1377 scroll_handle.set_offset(gpui::point(px(-10.), px(0.)));
1379 for _ in 0..30 {
1380 next_frame(cx);
1381 }
1382 assert_eq!(scroll_handle.offset().x, px(-10.));
1383
1384 cx.simulate_click(
1386 tab.center() - gpui::point(px(10.), px(0.)),
1387 Modifiers::default(),
1388 );
1389 for _ in 0..60 {
1390 next_frame(cx);
1391 }
1392 assert_eq!(scroll_handle.offset().x, px(-52.));
1393 }
1394}