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 {
128 self.scroll_handle = Some(scroll_handle.clone());
129 self
130 }
131
132 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
134 self.prefix = Some(prefix.into_any_element());
135 self
136 }
137
138 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
140 self.suffix = Some(suffix.into_any_element());
141 self
142 }
143
144 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
146 self.children.extend(children.into_iter().map(Into::into));
147 self
148 }
149
150 pub fn child(mut self, child: impl Into<Tab>) -> Self {
152 self.children.push(child.into());
153 self
154 }
155
156 pub fn selected_index(mut self, index: usize) -> Self {
158 self.selected_index = Some(index);
159 self
160 }
161
162 pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
164 self.last_empty_space = last_empty_space.into_any_element();
165 self
166 }
167
168 pub fn on_click<F>(mut self, on_click: F) -> Self
172 where
173 F: Fn(&usize, &mut Window, &mut App) + 'static,
174 {
175 self.on_click = Some(Rc::new(on_click));
176 self
177 }
178
179 fn render_indicator(
186 &self,
187 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
188 window: &mut Window,
189 cx: &mut App,
190 ) -> Option<(AnyElement, u64)> {
191 let has_indicator = matches!(
192 self.variant,
193 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
194 );
195 let num_tabs = self.children.len();
196 let selected_ix = self.selected_index.unwrap_or(usize::MAX);
197
198 if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
199 return None;
200 }
201
202 let prev_key = format!("{}-tab-prev", self.id);
203 let anim_key = format!("{}-tab-anim", self.id);
204 let init_key = format!("{}-tab-init", self.id);
205
206 let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
207 let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
209 let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
210
211 if !*initialized.read(cx) {
213 initialized.update(cx, |v, _| *v = true);
214 }
215
216 self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
217
218 let (to_left, to_width, epoch) = *anim_params.read(cx);
219 if to_width <= px(0.) {
220 return None;
221 }
222
223 let indicator_key = format!("{}-tab-indicator", self.id);
227 let left = spring(
228 (indicator_key.clone(), "left"),
229 to_left,
230 cx.theme().motion_tokens().spring_move,
231 window,
232 cx,
233 );
234 let width = spring(
235 (indicator_key, "width"),
236 to_width,
237 cx.theme().motion_tokens().spring_move,
238 window,
239 cx,
240 );
241
242 let variant = self.variant;
243 let size = self.size;
244 let inner_height = variant.inner_height(size);
245 let inner_radius = variant.inner_radius(size, cx);
246
247 let indicator = div()
248 .absolute()
249 .top_0()
250 .bottom_0()
251 .left(left)
252 .w(width)
253 .map(|el| match variant {
254 TabVariant::Segmented => el.flex().items_center().child(
255 div()
256 .w_full()
257 .h(inner_height)
258 .bg(cx.theme().tokens.background)
259 .rounded(inner_radius)
260 .shadow(raised_shadow()),
261 ),
262 TabVariant::Pill => el.flex().items_center().child(
263 div()
264 .size_full()
265 .bg(cx.theme().tokens.primary)
266 .rounded(cx.theme().radius_full()),
267 ),
268 TabVariant::Underline => el.child(
269 div()
270 .absolute()
271 .left_0()
272 .right_0()
273 .bottom_0()
274 .h(px(2.))
275 .bg(cx.theme().tokens.primary),
276 ),
277 _ => el,
278 });
279
280 Some((indicator.into_any_element(), epoch))
281 }
282
283 fn update_anim_params(
285 &self,
286 selected_ix: usize,
287 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
288 prev_selected: &gpui::Entity<usize>,
289 anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
290 cx: &mut App,
291 ) {
292 let rc = match bounds_rc {
293 Some(rc) => rc,
294 None => return,
295 };
296
297 let prev_ix = *prev_selected.read(cx);
298 let bounds = rc.borrow();
299 let container = bounds.container;
300
301 if container.size.width == px(0.) {
302 if prev_ix != selected_ix {
303 prev_selected.update(cx, |v, _| *v = selected_ix);
304 }
305 return;
306 }
307
308 let first_tab_origin = bounds
311 .tabs
312 .first()
313 .map(|tab| tab.origin.x)
314 .unwrap_or(container.origin.x);
315
316 if prev_ix != selected_ix {
317 if let Some(to_b) = bounds.tabs.get(selected_ix) {
318 let left = to_b.origin.x - first_tab_origin;
319 let width = to_b.size.width;
320 let epoch = anim_params.read(cx).2;
323 let epoch = match bounds.tabs.get(prev_ix) {
324 Some(_) => epoch + 1,
325 None => epoch,
326 };
327 anim_params.update(cx, |v, _| *v = (left, width, epoch));
328 }
329 drop(bounds);
330 prev_selected.update(cx, |v, _| *v = selected_ix);
331 return;
332 }
333
334 if let Some(to_b) = bounds.tabs.get(selected_ix) {
335 let left = to_b.origin.x - first_tab_origin;
336 let width = to_b.size.width;
337 let (to_left, to_width, epoch) = *anim_params.read(cx);
338
339 if left != to_left || width != to_width {
340 anim_params.update(cx, |v, _| *v = (left, width, epoch));
341 }
342 }
343 }
344}
345
346impl Styled for TabBar {
347 fn style(&mut self) -> &mut StyleRefinement {
348 &mut self.style
349 }
350}
351
352impl Sizable for TabBar {
353 fn with_size(mut self, size: impl Into<Size>) -> Self {
354 self.size = size.into();
355 self
356 }
357}
358
359impl RenderOnce for TabBar {
360 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
361 let default_gap = match self.size {
362 Size::Small | Size::XSmall => px(8.),
363 Size::Large => px(16.),
364 _ => px(12.),
365 };
366 let (bg, paddings, gap): (Background, _, _) = match self.variant {
367 TabVariant::Tab => {
368 let padding = Edges::all(px(0.));
369 (cx.theme().tokens.tab_bar.into(), padding, px(0.))
370 }
371 TabVariant::Outline => {
372 let padding = Edges::all(px(0.));
373 (cx.theme().transparent.into(), padding, default_gap)
374 }
375 TabVariant::Pill => {
376 let padding = Edges::all(px(0.));
377 (cx.theme().transparent.into(), padding, px(4.))
378 }
379 TabVariant::Segmented => {
380 let padding_x = match self.size {
381 Size::XSmall => px(2.),
382 Size::Small => px(3.),
383 _ => px(4.),
384 };
385 let padding = Edges {
386 left: padding_x,
387 right: padding_x,
388 ..Default::default()
389 };
390
391 (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
392 }
393 TabVariant::Underline => {
394 let gap = match self.size {
396 Size::XSmall => px(10.),
397 Size::Small => px(12.),
398 Size::Large => px(20.),
399 _ => px(16.),
400 };
401
402 (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
403 }
404 };
405
406 let has_indicator = matches!(
407 self.variant,
408 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
409 );
410 let num_tabs = self.children.len();
411
412 let bounds_rc = if has_indicator && num_tabs > 0 {
415 let rc: Rc<RefCell<TabIndicatorBounds>> = window
416 .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
417 Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
418 })
419 .read(cx)
420 .clone();
421 rc.borrow_mut().resize(num_tabs);
422 Some(rc)
423 } else {
424 None
425 };
426
427 let padding_x = paddings.left;
428 let indicator = self.render_indicator(&bounds_rc, window, cx);
429 let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
430 let mut indicator_element = indicator.map(|(el, _)| el);
431 let indicator_ready = indicator_element.is_some();
432
433 let has_suffix_or_menu = self.suffix.is_some() || self.menu;
434 let mut item_metas: Vec<(Option<SharedString>, Option<Icon>, bool)> = Vec::new();
435 let selected_index = self.selected_index;
436 let on_click = self.on_click.clone();
437 let tabs = self.base;
438 let mut rendered_tabs = Vec::with_capacity(self.children.len());
439 let max_width = self.max_width;
440
441 for (ix, child) in self.children.into_iter().enumerate() {
442 item_metas.push((child.label.clone(), child.icon.clone(), child.disabled));
443 let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
444 let mut tab = child
445 .ix(ix)
446 .tab_bar_prefix(tab_bar_prefix)
447 .max_width(max_width)
448 .with_variant(self.variant)
449 .with_size(self.size);
450 tab.indicator_active = has_indicator;
451 tab.indicator_ready = indicator_ready;
452 tab.indicator_epoch = indicator_epoch;
453 let mut tab = tab
454 .when_some(selected_index, |tab, selected_index| {
455 tab.selected(selected_index == ix)
456 })
457 .when_some(self.on_click.clone(), move |tab, on_click| {
458 tab.on_click(move |_, window, cx| on_click(&ix, window, cx))
459 });
460 let flex_grow = tab.style().flex_grow;
463 let flex_basis = tab.style().flex_basis;
464
465 rendered_tabs.push(if let Some(ref rc) = bounds_rc {
466 let rc = rc.clone();
467 div()
471 .flex_shrink_0()
472 .map(|mut this| {
473 this.style().flex_grow = flex_grow;
474 this.style().flex_basis = flex_basis;
475 this
476 })
477 .on_prepaint(move |bounds, _, _| {
478 if let Some(slot) = rc.borrow_mut().tabs.get_mut(ix) {
479 *slot = bounds;
480 }
481 })
482 .relative()
483 .when(ix == 0, |this| {
484 this.when_some(indicator_element.take(), |this, indicator| {
485 this.child(indicator)
486 })
487 })
488 .child(tab)
489 .into_any_element()
490 } else {
491 tab.into_any_element()
492 });
493 }
494
495 tabs.group("tab-bar")
496 .relative()
497 .flex()
498 .items_center()
499 .bg(bg)
500 .text_color(cx.theme().tab_foreground)
501 .when(
502 self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
503 |this| {
504 this.child(
505 div()
506 .id("border-b")
507 .absolute()
508 .left_0()
509 .bottom_0()
510 .size_full()
511 .border_b_1()
512 .border_color(cx.theme().border),
513 )
514 },
515 )
516 .rounded(self.variant.tab_bar_radius(self.size, cx))
517 .paddings(paddings)
518 .refine_style(&self.style)
519 .when_some(self.prefix, |this, prefix| this.child(prefix))
520 .child(
521 h_flex()
522 .id("tabs")
523 .flex_1()
524 .min_w_0()
525 .mx(-padding_x)
526 .px(padding_x)
527 .overflow_x_hidden()
528 .when_some(bounds_rc.clone(), |this, rc| {
531 this.on_prepaint(move |bounds, _, _| {
532 rc.borrow_mut().container = bounds;
533 })
534 })
535 .child(
536 h_flex()
537 .id("tabs-inner")
538 .flex_1()
541 .relative()
544 .gap(gap)
545 .overflow_x_scroll()
546 .lock_scroll_axis()
547 .when_some(self.scroll_handle, |this, scroll_handle| {
548 this.track_scroll(&scroll_handle)
549 })
550 .children(rendered_tabs)
551 .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
552 ),
553 )
554 .when(self.menu, |this| {
555 this.child(
556 Button::new("more")
557 .xsmall()
558 .ghost()
559 .dropdown_caret(true)
560 .dropdown_menu(move |mut this, _, _| {
561 this = this.scrollable(true);
562 for (ix, (label, icon, disabled)) in item_metas.iter().enumerate() {
563 let base = if let Some(label) = label.clone() {
564 PopupMenuItem::new(label)
565 } else if let Some(icon) = icon.clone() {
566 PopupMenuItem::element(move |_, _| icon.clone())
567 } else {
568 PopupMenuItem::new(t!("Dock.Unnamed"))
569 };
570 this = this.item(
571 base.checked(selected_index == Some(ix))
572 .disabled(*disabled)
573 .when_some(on_click.clone(), |this, on_click| {
574 this.on_click(move |_, window, cx| {
575 on_click(&ix, window, cx)
576 })
577 }),
578 );
579 }
580
581 this
582 })
583 .anchor(Anchor::TopRight),
584 )
585 })
586 .when_some(self.suffix, |this, suffix| this.child(suffix))
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use std::{cell::Cell, rc::Rc};
593
594 use gpui::{Context, Modifiers, Render, TestAppContext};
595
596 use super::*;
597
598 struct Harness {
599 group_handler: bool,
600 disabled: bool,
601 child_clicks: Rc<Cell<usize>>,
602 group_clicks: Rc<Cell<usize>>,
603 }
604
605 impl Render for Harness {
606 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
607 let child_clicks = self.child_clicks.clone();
608 let group_clicks = self.group_clicks.clone();
609 TabBar::new("tabs")
610 .w(px(240.))
611 .child(
612 Tab::new()
613 .debug_selector(|| "first-tab".into())
614 .disabled(self.disabled)
615 .label("First")
616 .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
617 )
618 .when(self.group_handler, |tabs| {
619 tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
620 })
621 }
622 }
623
624 fn harness(
625 cx: &mut TestAppContext,
626 group_handler: bool,
627 disabled: bool,
628 ) -> (
629 &mut gpui::VisualTestContext,
630 Rc<Cell<usize>>,
631 Rc<Cell<usize>>,
632 ) {
633 cx.update(crate::theme::init);
634 let child_clicks = Rc::new(Cell::new(0));
635 let group_clicks = Rc::new(Cell::new(0));
636 let (_, cx) = cx.add_window_view({
637 let child_clicks = child_clicks.clone();
638 let group_clicks = group_clicks.clone();
639 move |_, _| Harness {
640 group_handler,
641 disabled,
642 child_clicks,
643 group_clicks,
644 }
645 });
646 cx.update(|window, cx| window.draw(cx).clear(cx));
647 (cx, child_clicks, group_clicks)
648 }
649
650 #[gpui::test]
651 fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
652 let (cx, child_clicks, group_clicks) = harness(cx, true, false);
653 let position = cx.debug_bounds("first-tab").unwrap().center();
654 cx.simulate_click(position, Modifiers::default());
655 assert_eq!(child_clicks.get(), 0);
656 assert_eq!(group_clicks.get(), 1);
657 }
658
659 #[gpui::test]
660 fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
661 let (cx, child_clicks, group_clicks) = harness(cx, false, false);
662 let position = cx.debug_bounds("first-tab").unwrap().center();
663 cx.simulate_click(position, Modifiers::default());
664 assert_eq!(child_clicks.get(), 1);
665 assert_eq!(group_clicks.get(), 0);
666 }
667
668 #[gpui::test]
669 fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
670 let (cx, child_clicks, group_clicks) = harness(cx, true, true);
671 let position = cx.debug_bounds("first-tab").unwrap().center();
672 cx.simulate_click(position, Modifiers::default());
673 assert_eq!(child_clicks.get(), 0);
674 assert_eq!(group_clicks.get(), 0);
675 }
676
677 struct ContentHarness;
678
679 impl Render for ContentHarness {
680 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
681 TabBar::new("content-tabs").w(px(320.)).child(
682 Tab::new()
683 .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
684 .child(div().debug_selector(|| "tab-child".into()).child("Content"))
685 .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
686 )
687 }
688 }
689
690 #[gpui::test]
691 fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
692 cx.update(crate::theme::init);
693 let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
694 cx.update(|window, cx| window.draw(cx).clear(cx));
695
696 let prefix = cx.debug_bounds("tab-prefix").unwrap();
697 let child = cx.debug_bounds("tab-child").unwrap();
698 let suffix = cx.debug_bounds("tab-suffix").unwrap();
699 assert!(prefix.origin.x < child.origin.x);
700 assert!(child.origin.x < suffix.origin.x);
701 assert!(prefix.size.width > px(0.));
702 assert!(child.size.width > px(0.));
703 assert!(suffix.size.width > px(0.));
704 }
705
706 struct ScrollHarness {
707 scroll_handle: ScrollHandle,
708 }
709
710 struct DynamicScrollHarness {
711 scroll_handle: ScrollHandle,
712 menu: bool,
713 size: Size,
714 tabs: usize,
715 selected_index: usize,
716 }
717
718 struct ManualScrollHarness {
719 scroll_handle: ScrollHandle,
720 tabs: usize,
721 selected_index: usize,
722 label: &'static str,
723 top: Pixels,
724 }
725
726 impl ScrollHarness {
727 fn tabs() -> impl Iterator<Item = Tab> {
728 (0..5).map(|ix| {
729 Tab::new()
730 .w(px(60.))
731 .label(format!("Tab {ix}"))
732 .debug_selector(move || format!("tab-{ix}"))
733 })
734 }
735 }
736
737 impl Render for ScrollHarness {
738 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
739 div().w(px(100.)).child(
740 TabBar::new("scrolling-tabs")
741 .w_full()
742 .segmented()
743 .menu(true)
744 .track_scroll(&self.scroll_handle)
745 .selected_index(4)
746 .children(Self::tabs()),
747 )
748 }
749 }
750
751 impl DynamicScrollHarness {
752 fn tabs(&self) -> impl Iterator<Item = Tab> {
753 (0..self.tabs).map(|ix| {
754 Tab::new()
755 .w(px(60.))
756 .label(format!("Tab {ix}"))
757 .debug_selector(move || format!("dynamic-tab-{ix}"))
758 })
759 }
760 }
761
762 impl Render for DynamicScrollHarness {
763 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
764 div()
765 .w(px(100.))
766 .debug_selector(|| "dynamic-bar".into())
767 .child(
768 TabBar::new("dynamic-scrolling-tabs")
769 .with_size(self.size)
770 .w_full()
771 .segmented()
772 .menu(self.menu)
773 .track_scroll(&self.scroll_handle)
774 .selected_index(self.selected_index)
775 .children(self.tabs()),
776 )
777 }
778 }
779
780 impl ManualScrollHarness {
781 fn tabs(&self) -> impl Iterator<Item = Tab> {
782 let width = if self.label == "old" {
783 px(60.)
784 } else {
785 px(120.)
786 };
787 let label = self.label;
788 (0..self.tabs).map(move |ix| {
789 Tab::new()
790 .w(width)
791 .label(format!("Tab {ix} {label}"))
792 .debug_selector(move || format!("manual-tab-{ix}"))
793 })
794 }
795 }
796
797 impl Render for ManualScrollHarness {
798 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
799 div().w(px(160.)).h(px(40.)).child(
800 div().relative().top(self.top).w_full().child(
801 TabBar::new("manual-scrolling-tabs")
802 .w_full()
803 .segmented()
804 .menu(true)
805 .track_scroll(&self.scroll_handle)
806 .selected_index(self.selected_index)
807 .children(self.tabs()),
808 ),
809 )
810 }
811 }
812
813 fn draw(cx: &mut gpui::VisualTestContext) {
814 cx.run_until_parked();
815 cx.update(|window, cx| window.draw(cx).clear(cx));
816 }
817
818 #[gpui::test]
819 fn scrolling_to_a_tab_uses_logical_tab_indices(cx: &mut TestAppContext) {
820 cx.update(crate::theme::init);
821 let scroll_handle = ScrollHandle::new();
822 let (_, cx) = cx.add_window_view({
823 let scroll_handle = scroll_handle.clone();
824 move |_, _| ScrollHarness { scroll_handle }
825 });
826
827 draw(cx);
828 draw(cx);
829 assert_eq!(scroll_handle.offset().x, px(0.));
830 scroll_handle.scroll_to_item(4);
831 draw(cx);
832 draw(cx);
833
834 let viewport = scroll_handle.bounds();
835 let last_tab = cx.debug_bounds("tab-4").unwrap();
836 assert!(
837 last_tab.left() >= viewport.left(),
838 "last tab {last_tab:?} is left of viewport {viewport:?}, offset {:?}",
839 scroll_handle.offset()
840 );
841 assert!(
842 last_tab.right() <= viewport.right(),
843 "last tab {last_tab:?} is right of viewport {viewport:?}, offset {:?}",
844 scroll_handle.offset()
845 );
846 assert_eq!(scroll_handle.children_count(), 6);
847 }
848
849 #[gpui::test]
850 fn scrolling_to_a_new_tab_preserves_the_explicit_target(cx: &mut TestAppContext) {
851 cx.update(crate::theme::init);
852 let scroll_handle = ScrollHandle::new();
853 let (view, cx) = cx.add_window_view({
854 let scroll_handle = scroll_handle.clone();
855 move |_, _| DynamicScrollHarness {
856 scroll_handle,
857 menu: true,
858 size: Size::default(),
859 tabs: 4,
860 selected_index: 3,
861 }
862 });
863
864 draw(cx);
865 draw(cx);
866
867 view.update(cx, |view, cx| {
868 view.tabs = 5;
869 view.selected_index = 4;
870 view.scroll_handle.scroll_to_item(4);
871 cx.notify();
872 });
873 draw(cx);
874
875 let viewport = scroll_handle.bounds();
876 let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
877 assert!(last_tab.left() >= viewport.left());
878 assert!(last_tab.right() <= viewport.right());
879 assert_eq!(scroll_handle.children_count(), 6);
880 }
881
882 #[gpui::test]
883 fn scrolling_to_a_new_tab_preserves_bar_padding(cx: &mut TestAppContext) {
884 cx.update(crate::theme::init);
885 for (size, padding) in [
886 (Size::XSmall, px(2.)),
887 (Size::Small, px(3.)),
888 (Size::Medium, px(4.)),
889 (Size::Large, px(4.)),
890 ] {
891 let scroll_handle = ScrollHandle::new();
892 let (view, cx) = cx.add_window_view({
893 let scroll_handle = scroll_handle.clone();
894 move |_, _| DynamicScrollHarness {
895 scroll_handle,
896 menu: false,
897 size,
898 tabs: 4,
899 selected_index: 0,
900 }
901 });
902 draw(cx);
903 draw(cx);
904 view.update(cx, |view, cx| {
905 view.tabs = 5;
906 view.scroll_handle.scroll_to_item(4);
907 cx.notify();
908 });
909 draw(cx);
910 draw(cx);
911 let bar = cx.debug_bounds("dynamic-bar").unwrap();
912 let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
913 assert_eq!(
914 bar.right() - last_tab.right(),
915 padding,
916 "right padding for {size:?}"
917 );
918 scroll_handle.scroll_to_item(0);
919 draw(cx);
920 draw(cx);
921 let first_tab = cx.debug_bounds("dynamic-tab-0").unwrap();
922 assert_eq!(
923 first_tab.left() - bar.left(),
924 padding,
925 "left padding for {size:?}"
926 );
927 assert_eq!(scroll_handle.children_count(), 5);
928 }
929 }
930
931 #[gpui::test]
932 fn closing_an_unselected_trailing_tab_preserves_manual_scrolling(cx: &mut TestAppContext) {
933 cx.update(crate::theme::init);
934 let scroll_handle = ScrollHandle::new();
935 let (view, cx) = cx.add_window_view({
936 let scroll_handle = scroll_handle.clone();
937 move |_, _| ManualScrollHarness {
938 scroll_handle,
939 tabs: 6,
940 selected_index: 0,
941 label: "old",
942 top: px(0.),
943 }
944 });
945
946 draw(cx);
947 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
948 draw(cx);
949 assert_eq!(scroll_handle.offset().x, px(-100.));
950
951 view.update(cx, |view, cx| {
952 view.tabs = 5;
953 cx.notify();
954 });
955 draw(cx);
956
957 assert_eq!(scroll_handle.offset().x, px(-100.));
958 }
959
960 #[gpui::test]
961 fn changing_selection_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
962 cx.update(crate::theme::init);
963 let scroll_handle = ScrollHandle::new();
964 let (view, cx) = cx.add_window_view({
965 let scroll_handle = scroll_handle.clone();
966 move |_, _| ManualScrollHarness {
967 scroll_handle,
968 tabs: 6,
969 selected_index: 0,
970 label: "old",
971 top: px(0.),
972 }
973 });
974
975 draw(cx);
976 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
977 draw(cx);
978
979 view.update(cx, |view, cx| {
980 view.selected_index = 5;
981 cx.notify();
982 });
983 draw(cx);
984
985 assert_eq!(scroll_handle.offset().x, px(-100.));
986 }
987
988 #[gpui::test]
989 fn changing_tab_labels_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
990 cx.update(crate::theme::init);
991 let scroll_handle = ScrollHandle::new();
992 let (view, cx) = cx.add_window_view({
993 let scroll_handle = scroll_handle.clone();
994 move |_, _| ManualScrollHarness {
995 scroll_handle,
996 tabs: 6,
997 selected_index: 0,
998 label: "old",
999 top: px(0.),
1000 }
1001 });
1002
1003 draw(cx);
1004 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1005 draw(cx);
1006
1007 view.update(cx, |view, cx| {
1008 view.label = "new";
1009 cx.notify();
1010 });
1011 draw(cx);
1012
1013 assert_eq!(scroll_handle.offset().x, px(-100.));
1014 }
1015
1016 #[gpui::test]
1017 fn moving_the_tab_bar_preserves_manual_scrolling(cx: &mut TestAppContext) {
1018 cx.update(crate::theme::init);
1019 let scroll_handle = ScrollHandle::new();
1020 let (view, cx) = cx.add_window_view({
1021 let scroll_handle = scroll_handle.clone();
1022 move |_, _| ManualScrollHarness {
1023 scroll_handle,
1024 tabs: 6,
1025 selected_index: 0,
1026 label: "old",
1027 top: px(0.),
1028 }
1029 });
1030
1031 draw(cx);
1032 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1033 draw(cx);
1034 assert_eq!(scroll_handle.offset().x, px(-100.));
1035 let viewport_size = scroll_handle.bounds().size;
1036
1037 view.update(cx, |view, cx| {
1038 view.top = px(20.);
1039 cx.notify();
1040 });
1041 draw(cx);
1042
1043 assert_eq!(scroll_handle.bounds().size, viewport_size);
1044 assert_eq!(scroll_handle.offset().x, px(-100.));
1045 }
1046
1047 struct FlexHarness {
1048 variant: TabVariant,
1049 }
1050
1051 impl Render for FlexHarness {
1052 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1053 div()
1054 .w(px(200.))
1055 .debug_selector(|| "flex-bar".into())
1056 .child(
1057 TabBar::new("flex-tabs")
1058 .w_full()
1059 .with_variant(self.variant)
1060 .selected_index(0)
1061 .child(
1062 Tab::new()
1063 .flex_1()
1064 .label("A")
1065 .debug_selector(|| "flex-tab-0".into()),
1066 )
1067 .child(
1068 Tab::new()
1069 .flex_1()
1070 .label("B")
1071 .debug_selector(|| "flex-tab-1".into()),
1072 ),
1073 )
1074 }
1075 }
1076
1077 #[gpui::test]
1078 fn flex_tabs_share_the_available_width(cx: &mut TestAppContext) {
1079 cx.update(crate::theme::init);
1080 for variant in [
1081 TabVariant::Tab,
1082 TabVariant::Outline,
1083 TabVariant::Segmented,
1084 TabVariant::Pill,
1085 TabVariant::Underline,
1086 ] {
1087 let (_, cx) = cx.add_window_view(move |_, _| FlexHarness { variant });
1088 draw(cx);
1089 draw(cx);
1090
1091 let bar = cx.debug_bounds("flex-bar").unwrap();
1092 let first = cx.debug_bounds("flex-tab-0").unwrap();
1093 let second = cx.debug_bounds("flex-tab-1").unwrap();
1094 assert_eq!(first.size.width, second.size.width, "{variant:?}");
1095 assert_eq!(
1097 first.left() - bar.left(),
1098 bar.right() - second.right(),
1099 "{variant:?}"
1100 );
1101 assert!(
1102 bar.right() - second.right() <= px(4.),
1103 "{variant:?}: tabs end at {:?} but the bar ends at {:?}",
1104 second.right(),
1105 bar.right()
1106 );
1107 }
1108 }
1109}