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::{
14 Tab, TabVariant,
15 tab::{FOLDER_TAB_GAP, FolderTabMetrics},
16};
17use crate::button::{Button, ButtonVariants as _};
18use crate::menu::{DropdownMenu as _, PopupMenuItem};
19use crate::scroll::ScrollableMask;
20use crate::{
21 ActiveTheme, ElementExt, Icon, InteractiveElementExt as _, Selectable, Sizable, Size,
22 StyledExt, h_flex, styled::raised_shadow,
23};
24
25struct TabIndicatorBounds {
26 container: Bounds<Pixels>,
27 tabs: Vec<Bounds<Pixels>>,
28 placed: (Pixels, Pixels),
31}
32
33impl TabIndicatorBounds {
34 fn new(num_tabs: usize) -> Self {
35 Self {
36 container: Bounds::default(),
37 tabs: vec![Bounds::default(); num_tabs],
38 placed: (px(0.), px(0.)),
39 }
40 }
41
42 fn resize(&mut self, num_tabs: usize) {
43 self.tabs.resize(num_tabs, Bounds::default());
44 }
45}
46
47#[derive(IntoElement)]
49pub struct TabBar {
50 id: ElementId,
51 base: gpui_base::Tabs,
52 style: StyleRefinement,
53 scroll_handle: Option<ScrollHandle>,
54 prefix: Option<AnyElement>,
55 suffix: Option<AnyElement>,
56 children: SmallVec<[Tab; 2]>,
57 last_empty_space: AnyElement,
58 selected_index: Option<usize>,
59 variant: TabVariant,
60 size: Size,
61 menu: bool,
62 max_width: Option<Pixels>,
63 on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
64}
65
66impl TabBar {
67 pub fn new(id: impl Into<ElementId>) -> Self {
69 let id = id.into();
70 Self {
71 id: id.clone(),
72 base: gpui_base::Tabs::new(id).px(px(-1.)),
73 style: StyleRefinement::default(),
74 children: SmallVec::new(),
75 scroll_handle: None,
76 prefix: None,
77 suffix: None,
78 variant: TabVariant::default(),
79 size: Size::default(),
80 last_empty_space: div().w_3().into_any_element(),
81 selected_index: None,
82 on_click: None,
83 menu: false,
84 max_width: None,
85 }
86 }
87
88 pub fn with_variant(mut self, variant: TabVariant) -> Self {
90 self.variant = variant;
91 self
92 }
93
94 pub fn pill(mut self) -> Self {
96 self.variant = TabVariant::Pill;
97 self
98 }
99
100 pub fn outline(mut self) -> Self {
102 self.variant = TabVariant::Outline;
103 self
104 }
105
106 pub fn segmented(mut self) -> Self {
108 self.variant = TabVariant::Segmented;
109 self
110 }
111
112 pub fn underline(mut self) -> Self {
114 self.variant = TabVariant::Underline;
115 self
116 }
117
118 pub fn folder(mut self) -> Self {
120 self.variant = TabVariant::Folder;
121 self
122 }
123
124 pub fn menu(mut self, menu: bool) -> Self {
126 self.menu = menu;
127 self
128 }
129
130 pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
134 self.max_width = Some(width.into());
135 self
136 }
137
138 pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
144 self.scroll_handle = Some(scroll_handle.clone());
145 self
146 }
147
148 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
150 self.prefix = Some(prefix.into_any_element());
151 self
152 }
153
154 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
156 self.suffix = Some(suffix.into_any_element());
157 self
158 }
159
160 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
162 self.children.extend(children.into_iter().map(Into::into));
163 self
164 }
165
166 pub fn child(mut self, child: impl Into<Tab>) -> Self {
168 self.children.push(child.into());
169 self
170 }
171
172 pub fn selected_index(mut self, index: usize) -> Self {
174 self.selected_index = Some(index);
175 self
176 }
177
178 pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
180 self.last_empty_space = last_empty_space.into_any_element();
181 self
182 }
183
184 pub fn on_click<F>(mut self, on_click: F) -> Self
188 where
189 F: Fn(&usize, &mut Window, &mut App) + 'static,
190 {
191 self.on_click = Some(Rc::new(on_click));
192 self
193 }
194
195 fn render_indicator(
202 &self,
203 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
204 window: &mut Window,
205 cx: &mut App,
206 ) -> Option<(AnyElement, u64)> {
207 let has_indicator = matches!(
208 self.variant,
209 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
210 );
211 let num_tabs = self.children.len();
212 let selected_ix = self.selected_index.unwrap_or(usize::MAX);
213
214 if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
215 return None;
216 }
217
218 let prev_key = format!("{}-tab-prev", self.id);
219 let anim_key = format!("{}-tab-anim", self.id);
220 let init_key = format!("{}-tab-init", self.id);
221
222 let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
223 let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
225 let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
226
227 if !*initialized.read(cx) {
229 initialized.update(cx, |v, _| *v = true);
230 }
231
232 self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
233
234 let (to_left, to_width, epoch) = *anim_params.read(cx);
235 if let Some(rc) = bounds_rc {
236 rc.borrow_mut().placed = (to_left, to_width);
237 }
238 if to_width <= px(0.) {
239 return None;
240 }
241
242 let indicator_key = format!("{}-tab-indicator", self.id);
246 let left = spring(
247 (indicator_key.clone(), "left"),
248 to_left,
249 cx.theme().motion_tokens().spring_move,
250 window,
251 cx,
252 );
253 let width = spring(
254 (indicator_key, "width"),
255 to_width,
256 cx.theme().motion_tokens().spring_move,
257 window,
258 cx,
259 );
260
261 let variant = self.variant;
262 let size = self.size;
263 let inner_height = variant.inner_height(size);
264 let inner_radius = variant.inner_radius(size, cx);
265
266 let indicator = div()
267 .absolute()
268 .top_0()
269 .bottom_0()
270 .left(left)
271 .w(width)
272 .map(|el| match variant {
273 TabVariant::Segmented => el.flex().items_center().child(
274 div()
275 .w_full()
276 .h(inner_height)
277 .bg(cx.theme().tokens.background)
278 .rounded(inner_radius)
279 .shadow(raised_shadow()),
280 ),
281 TabVariant::Pill => el.flex().items_center().child(
282 div()
283 .size_full()
284 .bg(cx.theme().tokens.primary)
285 .rounded(cx.theme().radius_full()),
286 ),
287 TabVariant::Underline => el.child(
288 div()
289 .absolute()
290 .left_0()
291 .right_0()
292 .bottom_0()
293 .h(px(2.))
294 .bg(cx.theme().tokens.primary),
295 ),
296 _ => el,
297 });
298
299 Some((indicator.into_any_element(), epoch))
300 }
301
302 fn update_anim_params(
304 &self,
305 selected_ix: usize,
306 bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
307 prev_selected: &gpui::Entity<usize>,
308 anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
309 cx: &mut App,
310 ) {
311 let rc = match bounds_rc {
312 Some(rc) => rc,
313 None => return,
314 };
315
316 let prev_ix = *prev_selected.read(cx);
317 let bounds = rc.borrow();
318 let container = bounds.container;
319
320 if container.size.width == px(0.) {
321 if prev_ix != selected_ix {
322 prev_selected.update(cx, |v, _| *v = selected_ix);
323 }
324 return;
325 }
326
327 let first_tab_origin = bounds
330 .tabs
331 .first()
332 .map(|tab| tab.origin.x)
333 .unwrap_or(container.origin.x);
334
335 if prev_ix != selected_ix {
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 epoch = anim_params.read(cx).2;
342 let epoch = match bounds.tabs.get(prev_ix) {
343 Some(_) => epoch + 1,
344 None => epoch,
345 };
346 anim_params.update(cx, |v, _| *v = (left, width, epoch));
347 }
348 drop(bounds);
349 prev_selected.update(cx, |v, _| *v = selected_ix);
350 return;
351 }
352
353 if let Some(to_b) = bounds.tabs.get(selected_ix) {
354 let left = to_b.origin.x - first_tab_origin;
355 let width = to_b.size.width;
356 let (to_left, to_width, epoch) = *anim_params.read(cx);
357
358 if left != to_left || width != to_width {
359 anim_params.update(cx, |v, _| *v = (left, width, epoch));
360 }
361 }
362 }
363}
364
365impl Styled for TabBar {
366 fn style(&mut self) -> &mut StyleRefinement {
367 &mut self.style
368 }
369}
370
371impl Sizable for TabBar {
372 fn with_size(mut self, size: impl Into<Size>) -> Self {
373 self.size = size.into();
374 self
375 }
376}
377
378impl RenderOnce for TabBar {
379 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
380 let default_gap = match self.size {
381 Size::Small | Size::XSmall => px(8.),
382 Size::Large => px(16.),
383 _ => px(12.),
384 };
385 let (bg, paddings, gap): (Background, _, _) = match self.variant {
386 TabVariant::Tab => {
387 let padding = Edges::all(px(0.));
388 (cx.theme().tokens.tab_bar.into(), padding, px(0.))
389 }
390 TabVariant::Outline => {
391 let padding = Edges::all(px(0.));
392 (cx.theme().transparent.into(), padding, default_gap)
393 }
394 TabVariant::Pill => {
395 let padding = Edges::all(px(0.));
396 (cx.theme().transparent.into(), padding, px(4.))
397 }
398 TabVariant::Segmented => {
399 let padding_x = match self.size {
400 Size::XSmall => px(2.),
401 Size::Small => px(3.),
402 _ => px(4.),
403 };
404 let padding = Edges {
405 left: padding_x,
406 right: padding_x,
407 ..Default::default()
408 };
409
410 (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
411 }
412 TabVariant::Underline => {
413 let gap = match self.size {
415 Size::XSmall => px(10.),
416 Size::Small => px(12.),
417 Size::Large => px(20.),
418 _ => px(16.),
419 };
420
421 (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
422 }
423 TabVariant::Folder => (
424 cx.theme().tokens.tab_bar.into(),
425 Edges::all(px(0.)),
426 FOLDER_TAB_GAP,
427 ),
428 };
429 let overhang = self.variant.overhang(self.size, cx);
430 let tabs_top_padding = match self.variant {
433 TabVariant::Folder => FolderTabMetrics::new(self.size, cx).top_padding,
434 _ => px(0.),
435 };
436
437 let has_indicator = matches!(
438 self.variant,
439 TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
440 );
441 let num_tabs = self.children.len();
442
443 let bounds_rc = if has_indicator && num_tabs > 0 {
446 let rc: Rc<RefCell<TabIndicatorBounds>> = window
447 .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
448 Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
449 })
450 .read(cx)
451 .clone();
452 rc.borrow_mut().resize(num_tabs);
453 Some(rc)
454 } else {
455 None
456 };
457
458 let scroll_handle = self.scroll_handle.clone().unwrap_or_else(|| {
460 window
461 .use_keyed_state(format!("{}-scroll", self.id), cx, |_, _| {
462 ScrollHandle::new()
463 })
464 .read(cx)
465 .clone()
466 });
467 let reveal = TabReveal {
468 spring_id: format!("{}-reveal", self.id).into(),
469 target: window.use_keyed_state(format!("{}-reveal", self.id), cx, |_, _| None),
470 scroll_handle: scroll_handle.clone(),
471 view: window.current_view(),
472 peek: match self.size {
473 Size::XSmall | Size::Small => px(24.),
474 Size::Large => px(40.),
475 _ => px(32.),
476 },
477 num_tabs,
478 };
479 reveal.advance(window, cx);
480
481 let padding_x = paddings.left;
482 let indicator = self.render_indicator(&bounds_rc, window, cx);
483 let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
484 let mut indicator_element = indicator.map(|(el, _)| el);
485 let indicator_ready = indicator_element.is_some();
486
487 let has_suffix_or_menu = self.suffix.is_some() || self.menu;
488 let view = window.current_view();
489 let mut item_metas: Vec<(
490 Option<SharedString>,
491 Option<Icon>,
492 bool,
493 Option<TabClickHandler>,
494 )> = Vec::new();
495 let selected_index = self.selected_index;
496 let tabs = self.base;
497 let mut rendered_tabs = Vec::with_capacity(self.children.len());
498 let max_width = self.max_width;
499 let hovered_tab = (self.variant == TabVariant::Folder).then(|| {
502 window.use_keyed_state(format!("{}-tab-hovered", self.id), cx, |_, _| None::<usize>)
503 });
504 let hovered_ix = hovered_tab.as_ref().and_then(|state| *state.read(cx));
505 let has_shape = |ix| selected_index == Some(ix) || hovered_ix == Some(ix);
506
507 for (ix, child) in self.children.into_iter().enumerate() {
508 item_metas.push((
509 child.label.clone(),
510 child.icon.clone(),
511 child.disabled,
512 None,
513 ));
514 let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
515 let mut tab = child
516 .ix(ix)
517 .tab_bar_prefix(tab_bar_prefix)
518 .max_width(max_width)
519 .with_variant(self.variant)
520 .with_size(self.size);
521 tab.indicator_active = has_indicator;
522 tab.indicator_ready = indicator_ready;
523 tab.indicator_epoch = indicator_epoch;
524 tab.hovered = hovered_ix == Some(ix);
525 tab.separator = self.variant == TabVariant::Folder
526 && ix + 1 < num_tabs
527 && !has_shape(ix)
528 && !has_shape(ix + 1);
529 let disabled = tab.disabled;
530 let mut tab = tab
531 .when_some(selected_index, |tab, selected_index| {
532 tab.selected(selected_index == ix)
533 })
534 .map(|mut tab| {
535 let on_click: Option<TabClickHandler> = match self.on_click.clone() {
537 Some(on_click) => {
538 Some(Rc::new(move |_, window, cx| on_click(&ix, window, cx)))
539 }
540 None => tab.on_click.take(),
541 };
542 tab.on_click = on_click.map(|on_click| {
543 let reveal = reveal.clone();
544 Rc::new(
545 move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
546 reveal.reveal(ix, cx);
547 on_click(event, window, cx)
548 },
549 ) as TabClickHandler
550 });
551 tab
552 });
553 if let Some(meta) = item_metas.last_mut() {
556 meta.3 = tab.on_click.clone();
557 }
558 let flex_grow = tab.style().flex_grow;
561 let flex_basis = tab.style().flex_basis;
562
563 rendered_tabs.push(if let Some(ref rc) = bounds_rc {
564 let rc = rc.clone();
565 let selected = selected_index == Some(ix);
566 div()
570 .flex_shrink_0()
571 .map(|mut this| {
572 this.style().flex_grow = flex_grow;
573 this.style().flex_basis = flex_basis;
574 this
575 })
576 .on_prepaint(move |bounds, window, _| {
577 let mut measured = rc.borrow_mut();
578 if let Some(slot) = measured.tabs.get_mut(ix) {
579 *slot = bounds;
580 }
581 if selected && measured.container.size.width > px(0.) {
589 let first = measured
590 .tabs
591 .first()
592 .map_or(bounds.origin.x, |tab| tab.origin.x);
593 if (bounds.origin.x - first, bounds.size.width) != measured.placed {
594 window.on_next_frame(move |_, cx| cx.notify(view));
595 }
596 }
597 })
598 .relative()
599 .when(ix == 0, |this| {
600 this.when_some(indicator_element.take(), |this, indicator| {
601 this.child(indicator)
602 })
603 })
604 .child(tab)
605 .into_any_element()
606 } else if let Some(hovered_tab) = hovered_tab.clone() {
607 div()
608 .id(("folder-tab", ix))
609 .flex_shrink_0()
610 .map(|mut this| {
611 this.style().flex_grow = flex_grow;
612 this.style().flex_basis = flex_basis;
613 this
614 })
615 .when(!disabled, |this| {
616 this.on_hover(move |hovered, _, cx| {
617 hovered_tab.update(cx, |state, cx| {
618 let next = match *hovered {
619 true => Some(ix),
620 false if *state == Some(ix) => None,
621 false => *state,
622 };
623 if next != *state {
624 *state = next;
625 cx.notify();
626 }
627 });
628 })
629 })
630 .child(tab)
631 .into_any_element()
632 } else {
633 tab.into_any_element()
634 });
635 }
636
637 tabs.group("tab-bar")
638 .relative()
639 .flex()
640 .items_center()
641 .bg(bg)
642 .text_color(cx.theme().tab_foreground)
643 .when(
644 self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
645 |this| {
646 this.child(
647 div()
648 .id("border-b")
649 .absolute()
650 .left_0()
651 .bottom_0()
652 .size_full()
653 .border_b_1()
654 .border_color(cx.theme().border),
655 )
656 },
657 )
658 .rounded(self.variant.tab_bar_radius(self.size, cx))
659 .paddings(paddings)
660 .refine_style(&self.style)
661 .when_some(self.prefix, |this, prefix| this.child(prefix))
662 .child(
663 h_flex()
664 .id("tabs")
665 .flex_1()
666 .min_w_0()
667 .mx(-padding_x)
668 .px(padding_x)
669 .overflow_x_hidden()
670 .when_some(bounds_rc.clone(), |this, rc| {
673 this.on_prepaint(move |bounds, _, _| {
674 rc.borrow_mut().container = bounds;
675 })
676 })
677 .child(
678 h_flex()
679 .id("tabs-inner")
680 .flex_1()
683 .relative()
686 .px(overhang)
688 .pt(tabs_top_padding)
689 .gap(gap)
690 .overflow_x_scroll()
691 .lock_scroll_axis()
692 .track_scroll(&scroll_handle)
693 .children(rendered_tabs)
694 .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
695 )
696 .child(ScrollableMask::vertical_wheel(&scroll_handle)),
698 )
699 .when(self.menu, |this| {
700 this.child(
701 Button::new("more")
702 .xsmall()
703 .ghost()
704 .dropdown_caret(true)
705 .dropdown_menu(move |mut this, _, _| {
706 this = this.scrollable(true);
707 for (ix, (label, icon, disabled, on_click)) in
708 item_metas.iter().enumerate()
709 {
710 let base = if let Some(label) = label.clone() {
711 PopupMenuItem::new(label)
712 } else if let Some(icon) = icon.clone() {
713 PopupMenuItem::element(move |_, _| icon.clone())
714 } else {
715 PopupMenuItem::new(t!("Dock.Unnamed"))
716 };
717 this = this.item(
718 base.checked(selected_index == Some(ix))
719 .disabled(*disabled)
720 .when_some(on_click.clone(), |this, on_click| {
721 this.on_click(move |event, window, cx| {
722 on_click(event, window, cx)
723 })
724 }),
725 );
726 }
727
728 this
729 })
730 .anchor(Anchor::TopRight),
731 )
732 })
733 .when_some(self.suffix, |this, suffix| this.child(suffix))
734 }
735}
736
737type TabClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
738
739#[derive(Clone)]
744struct TabReveal {
745 spring_id: SharedString,
746 target: Entity<Option<RevealTarget>>,
748 scroll_handle: ScrollHandle,
749 view: EntityId,
750 peek: Pixels,
752 num_tabs: usize,
753}
754
755#[derive(Clone, Copy)]
756struct RevealTarget {
757 to: Pixels,
758 last: Option<Pixels>,
760}
761
762impl TabReveal {
763 fn reveal(&self, ix: usize, cx: &mut App) {
764 let Some(to) = self.offset_revealing(ix) else {
765 return;
766 };
767 let running = self.target.read(cx).is_some();
768 if !running && to == self.scroll_handle.offset().x {
769 return;
770 }
771 self.target.update(cx, |target, _| match target {
772 Some(target) => target.to = to,
774 None => *target = Some(RevealTarget { to, last: None }),
775 });
776 cx.notify(self.view);
777 }
778
779 fn offset_revealing(&self, ix: usize) -> Option<Pixels> {
782 let handle = &self.scroll_handle;
783 let viewport = handle.bounds();
784 let tab = handle.bounds_for_item(ix)?;
785 let max = handle.max_offset().x;
786 let want_left = match ix.checked_sub(1).and_then(|ix| handle.bounds_for_item(ix)) {
789 Some(prev) => (prev.right() - self.peek).max(prev.left()),
790 None => viewport.left(),
791 };
792 let want_right = match (ix + 1 < self.num_tabs)
793 .then(|| handle.bounds_for_item(ix + 1))
794 .flatten()
795 {
796 Some(next) => (next.left() + self.peek).min(next.right()),
797 None => viewport.right() + max,
798 };
799
800 let mut offset = handle.offset().x;
801 let show_left = |offset: &mut Pixels, left: Pixels| {
802 if left < viewport.left() - *offset {
803 *offset = viewport.left() - left;
804 }
805 };
806 let show_right = |offset: &mut Pixels, right: Pixels| {
807 if right > viewport.right() - *offset {
808 *offset = viewport.right() - right;
809 }
810 };
811 if tab.center().x + offset > viewport.center().x {
814 show_left(&mut offset, want_left);
815 show_right(&mut offset, want_right);
816 } else {
817 show_right(&mut offset, want_right);
818 show_left(&mut offset, want_left);
819 }
820 show_right(&mut offset, tab.right());
821 show_left(&mut offset, tab.left());
822 Some(offset.min(px(0.)).max(-max))
823 }
824
825 fn advance(&self, window: &mut Window, cx: &mut App) {
827 let Some(target) = *self.target.read(cx) else {
828 return;
829 };
830 let offset = self.scroll_handle.offset();
831 if target.last.is_some_and(|last| last != offset.x) {
833 self.target.update(cx, |target, _| *target = None);
834 return;
835 }
836
837 let policy = cx.theme().motion_tokens().spring_move;
838 if target.last.is_none() {
839 spring(
841 self.spring_id.clone(),
842 offset.x,
843 policy.with_travel(false),
844 window,
845 cx,
846 );
847 }
848 let x = spring(self.spring_id.clone(), target.to, policy, window, cx);
849 self.scroll_handle.set_offset(point(x, offset.y));
850 self.target.update(cx, |state, _| {
851 *state = (x != target.to).then_some(RevealTarget {
852 last: Some(x),
853 ..target
854 })
855 });
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use std::{
862 cell::{Cell, RefCell},
863 rc::Rc,
864 };
865
866 use gpui::{Context, Modifiers, MouseButton, MouseUpEvent, Render, TestAppContext};
867
868 use super::*;
869 use crate::scroll::ScrollableElement as _;
870
871 struct Harness {
872 group_handler: bool,
873 disabled: bool,
874 child_clicks: Rc<Cell<usize>>,
875 group_clicks: Rc<Cell<usize>>,
876 }
877
878 impl Render for Harness {
879 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
880 let child_clicks = self.child_clicks.clone();
881 let group_clicks = self.group_clicks.clone();
882 TabBar::new("tabs")
883 .w(px(240.))
884 .child(
885 Tab::new()
886 .debug_selector(|| "first-tab".into())
887 .disabled(self.disabled)
888 .label("First")
889 .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
890 )
891 .when(self.group_handler, |tabs| {
892 tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
893 })
894 }
895 }
896
897 fn harness(
898 cx: &mut TestAppContext,
899 group_handler: bool,
900 disabled: bool,
901 ) -> (
902 &mut gpui::VisualTestContext,
903 Rc<Cell<usize>>,
904 Rc<Cell<usize>>,
905 ) {
906 cx.update(crate::theme::init);
907 let child_clicks = Rc::new(Cell::new(0));
908 let group_clicks = Rc::new(Cell::new(0));
909 let (_, cx) = cx.add_window_view({
910 let child_clicks = child_clicks.clone();
911 let group_clicks = group_clicks.clone();
912 move |_, _| Harness {
913 group_handler,
914 disabled,
915 child_clicks,
916 group_clicks,
917 }
918 });
919 cx.update(|window, cx| window.draw(cx).clear(cx));
920 (cx, child_clicks, group_clicks)
921 }
922
923 #[gpui::test]
924 fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
925 let (cx, child_clicks, group_clicks) = harness(cx, true, false);
926 let position = cx.debug_bounds("first-tab").unwrap().center();
927 cx.simulate_click(position, Modifiers::default());
928 assert_eq!(child_clicks.get(), 0);
929 assert_eq!(group_clicks.get(), 1);
930 }
931
932 #[gpui::test]
933 fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
934 let (cx, child_clicks, group_clicks) = harness(cx, false, false);
935 let position = cx.debug_bounds("first-tab").unwrap().center();
936 cx.simulate_click(position, Modifiers::default());
937 assert_eq!(child_clicks.get(), 1);
938 assert_eq!(group_clicks.get(), 0);
939 }
940
941 #[gpui::test]
942 fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
943 let (cx, child_clicks, group_clicks) = harness(cx, true, true);
944 let position = cx.debug_bounds("first-tab").unwrap().center();
945 cx.simulate_click(position, Modifiers::default());
946 assert_eq!(child_clicks.get(), 0);
947 assert_eq!(group_clicks.get(), 0);
948 }
949
950 struct MenuHarness {
951 group_handler: bool,
952 clicks: Rc<RefCell<Vec<String>>>,
953 events: Rc<RefCell<Vec<ClickEvent>>>,
954 }
955
956 impl Render for MenuHarness {
957 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
958 let events = self.events.clone();
959 let tab = |label: &'static str, clicks: Rc<RefCell<Vec<String>>>| {
960 let events = events.clone();
961 Tab::new()
962 .debug_selector(move || format!("{label}-tab"))
963 .label(label)
964 .on_click(move |event, _, _| {
965 clicks.borrow_mut().push(format!("tab {label}"));
966 events.borrow_mut().push(event.clone());
967 })
968 };
969 let clicks = self.clicks.clone();
970 div().size_full().child(
971 TabBar::new("menu-tabs")
972 .w(px(240.))
973 .menu(true)
974 .child(tab("First", self.clicks.clone()))
975 .child(tab("Second", self.clicks.clone()))
976 .when(self.group_handler, |tabs| {
977 tabs.on_click(move |ix, _, _| clicks.borrow_mut().push(format!("bar {ix}")))
978 }),
979 )
980 }
981 }
982
983 fn choose_first_menu_item(
985 group_handler: bool,
986 activate: impl FnOnce(&mut gpui::VisualTestContext),
987 cx: &mut TestAppContext,
988 ) -> (Vec<String>, Vec<ClickEvent>) {
989 cx.update(crate::init);
990 let clicks = Rc::new(RefCell::new(Vec::new()));
991 let events = Rc::new(RefCell::new(Vec::new()));
992 let (_, cx) = cx.add_window_view({
993 let clicks = clicks.clone();
994 let events = events.clone();
995 move |_, _| MenuHarness {
996 group_handler,
997 clicks,
998 events,
999 }
1000 });
1001 cx.update(|window, cx| window.draw(cx).clear(cx));
1002 let tab = cx.debug_bounds("First-tab").unwrap();
1003 let more = point(px(232.), tab.center().y);
1005 cx.simulate_mouse_down(more, MouseButton::Left, Modifiers::default());
1006 cx.simulate_event(MouseUpEvent {
1007 position: more,
1008 button: MouseButton::Left,
1009 modifiers: Modifiers::default(),
1010 click_count: 1,
1011 });
1012 cx.run_until_parked();
1013 cx.update(|window, cx| window.draw(cx).clear(cx));
1014 activate(cx);
1015 cx.run_until_parked();
1016 (clicks.borrow().clone(), events.borrow().clone())
1017 }
1018
1019 #[gpui::test]
1020 fn overflow_menu_runs_the_tab_callback_without_a_bar_callback(cx: &mut TestAppContext) {
1021 assert_eq!(
1022 choose_first_menu_item(false, |cx| cx.simulate_keystrokes("down enter"), cx).0,
1023 vec!["tab First".to_string()]
1024 );
1025 }
1026
1027 #[gpui::test]
1028 fn overflow_menu_prefers_the_bar_callback(cx: &mut TestAppContext) {
1029 assert_eq!(
1030 choose_first_menu_item(true, |cx| cx.simulate_keystrokes("down enter"), cx).0,
1031 vec!["bar 0".to_string()]
1032 );
1033 }
1034
1035 #[cfg(feature = "test-support")]
1036 #[gpui::test]
1037 fn overflow_menu_preserves_pointer_events_and_keyboard_activation(cx: &mut TestAppContext) {
1038 let position = Cell::new(point(px(0.), px(0.)));
1039 let (clicks, events) = choose_first_menu_item(
1040 false,
1041 |cx| {
1042 let item = cx.update(|window, _| {
1043 gpui_base::test_support::find_all(window, &[], &ElementId::from(0usize))
1044 .into_iter()
1045 .find(|item| item.role() == Some(gpui::Role::MenuItem))
1046 .expect("first overflow menu item")
1047 });
1048 position.set(item.bounds().center());
1049 cx.simulate_click(
1050 position.get(),
1051 Modifiers {
1052 shift: true,
1053 ..Modifiers::default()
1054 },
1055 );
1056 },
1057 cx,
1058 );
1059 assert_eq!(clicks, vec!["tab First"]);
1060 assert_eq!(events.len(), 1);
1061 assert!(matches!(events[0], ClickEvent::Mouse(_)));
1062 assert!(events[0].modifiers().shift);
1063 assert_eq!(events[0].mouse_position(), Some(position.get()));
1064
1065 let (clicks, events) =
1066 choose_first_menu_item(false, |cx| cx.simulate_keystrokes("down enter"), cx);
1067 assert_eq!(clicks, vec!["tab First"]);
1068 assert_eq!(events.len(), 1);
1069 assert!(matches!(events[0], ClickEvent::Keyboard(_)));
1070 }
1071
1072 struct ContentHarness;
1073
1074 impl Render for ContentHarness {
1075 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1076 TabBar::new("content-tabs").w(px(320.)).child(
1077 Tab::new()
1078 .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
1079 .child(div().debug_selector(|| "tab-child".into()).child("Content"))
1080 .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
1081 )
1082 }
1083 }
1084
1085 #[gpui::test]
1086 fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
1087 cx.update(crate::theme::init);
1088 let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
1089 cx.update(|window, cx| window.draw(cx).clear(cx));
1090
1091 let prefix = cx.debug_bounds("tab-prefix").unwrap();
1092 let child = cx.debug_bounds("tab-child").unwrap();
1093 let suffix = cx.debug_bounds("tab-suffix").unwrap();
1094 assert!(prefix.origin.x < child.origin.x);
1095 assert!(child.origin.x < suffix.origin.x);
1096 assert!(prefix.size.width > px(0.));
1097 assert!(child.size.width > px(0.));
1098 assert!(suffix.size.width > px(0.));
1099 }
1100
1101 struct ScrollHarness {
1102 scroll_handle: ScrollHandle,
1103 }
1104
1105 struct DynamicScrollHarness {
1106 scroll_handle: ScrollHandle,
1107 menu: bool,
1108 size: Size,
1109 tabs: usize,
1110 selected_index: usize,
1111 }
1112
1113 struct ManualScrollHarness {
1114 scroll_handle: ScrollHandle,
1115 tabs: usize,
1116 selected_index: usize,
1117 label: &'static str,
1118 top: Pixels,
1119 }
1120
1121 impl ScrollHarness {
1122 fn tabs() -> impl Iterator<Item = Tab> {
1123 (0..5).map(|ix| {
1124 Tab::new()
1125 .w(px(60.))
1126 .label(format!("Tab {ix}"))
1127 .debug_selector(move || format!("tab-{ix}"))
1128 })
1129 }
1130 }
1131
1132 impl Render for ScrollHarness {
1133 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1134 div().w(px(100.)).child(
1135 TabBar::new("scrolling-tabs")
1136 .w_full()
1137 .segmented()
1138 .menu(true)
1139 .track_scroll(&self.scroll_handle)
1140 .selected_index(4)
1141 .children(Self::tabs()),
1142 )
1143 }
1144 }
1145
1146 impl DynamicScrollHarness {
1147 fn tabs(&self) -> impl Iterator<Item = Tab> {
1148 (0..self.tabs).map(|ix| {
1149 Tab::new()
1150 .w(px(60.))
1151 .label(format!("Tab {ix}"))
1152 .debug_selector(move || format!("dynamic-tab-{ix}"))
1153 })
1154 }
1155 }
1156
1157 impl Render for DynamicScrollHarness {
1158 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1159 div()
1160 .w(px(100.))
1161 .debug_selector(|| "dynamic-bar".into())
1162 .child(
1163 TabBar::new("dynamic-scrolling-tabs")
1164 .with_size(self.size)
1165 .w_full()
1166 .segmented()
1167 .menu(self.menu)
1168 .track_scroll(&self.scroll_handle)
1169 .selected_index(self.selected_index)
1170 .children(self.tabs()),
1171 )
1172 }
1173 }
1174
1175 impl ManualScrollHarness {
1176 fn tabs(&self) -> impl Iterator<Item = Tab> {
1177 let width = if self.label == "old" {
1178 px(60.)
1179 } else {
1180 px(120.)
1181 };
1182 let label = self.label;
1183 (0..self.tabs).map(move |ix| {
1184 Tab::new()
1185 .w(width)
1186 .label(format!("Tab {ix} {label}"))
1187 .debug_selector(move || format!("manual-tab-{ix}"))
1188 })
1189 }
1190 }
1191
1192 impl Render for ManualScrollHarness {
1193 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1194 div().w(px(160.)).h(px(40.)).child(
1195 div().relative().top(self.top).w_full().child(
1196 TabBar::new("manual-scrolling-tabs")
1197 .w_full()
1198 .segmented()
1199 .menu(true)
1200 .track_scroll(&self.scroll_handle)
1201 .selected_index(self.selected_index)
1202 .children(self.tabs()),
1203 ),
1204 )
1205 }
1206 }
1207
1208 fn draw(cx: &mut gpui::VisualTestContext) {
1209 cx.run_until_parked();
1210 cx.update(|window, cx| window.draw(cx).clear(cx));
1211 }
1212
1213 #[gpui::test]
1214 fn scrolling_to_a_tab_uses_logical_tab_indices(cx: &mut TestAppContext) {
1215 cx.update(crate::theme::init);
1216 let scroll_handle = ScrollHandle::new();
1217 let (_, cx) = cx.add_window_view({
1218 let scroll_handle = scroll_handle.clone();
1219 move |_, _| ScrollHarness { scroll_handle }
1220 });
1221
1222 draw(cx);
1223 draw(cx);
1224 assert_eq!(scroll_handle.offset().x, px(0.));
1225 scroll_handle.scroll_to_item(4);
1226 draw(cx);
1227 draw(cx);
1228
1229 let viewport = scroll_handle.bounds();
1230 let last_tab = cx.debug_bounds("tab-4").unwrap();
1231 assert!(
1232 last_tab.left() >= viewport.left(),
1233 "last tab {last_tab:?} is left of viewport {viewport:?}, offset {:?}",
1234 scroll_handle.offset()
1235 );
1236 assert!(
1237 last_tab.right() <= viewport.right(),
1238 "last tab {last_tab:?} is right of viewport {viewport:?}, offset {:?}",
1239 scroll_handle.offset()
1240 );
1241 assert_eq!(scroll_handle.children_count(), 6);
1242 }
1243
1244 #[gpui::test]
1245 fn scrolling_to_a_new_tab_preserves_the_explicit_target(cx: &mut TestAppContext) {
1246 cx.update(crate::theme::init);
1247 let scroll_handle = ScrollHandle::new();
1248 let (view, cx) = cx.add_window_view({
1249 let scroll_handle = scroll_handle.clone();
1250 move |_, _| DynamicScrollHarness {
1251 scroll_handle,
1252 menu: true,
1253 size: Size::default(),
1254 tabs: 4,
1255 selected_index: 3,
1256 }
1257 });
1258
1259 draw(cx);
1260 draw(cx);
1261
1262 view.update(cx, |view, cx| {
1263 view.tabs = 5;
1264 view.selected_index = 4;
1265 view.scroll_handle.scroll_to_item(4);
1266 cx.notify();
1267 });
1268 draw(cx);
1269
1270 let viewport = scroll_handle.bounds();
1271 let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
1272 assert!(last_tab.left() >= viewport.left());
1273 assert!(last_tab.right() <= viewport.right());
1274 assert_eq!(scroll_handle.children_count(), 6);
1275 }
1276
1277 #[gpui::test]
1278 fn scrolling_to_a_new_tab_preserves_bar_padding(cx: &mut TestAppContext) {
1279 cx.update(crate::theme::init);
1280 for (size, padding) in [
1281 (Size::XSmall, px(2.)),
1282 (Size::Small, px(3.)),
1283 (Size::Medium, px(4.)),
1284 (Size::Large, px(4.)),
1285 ] {
1286 let scroll_handle = ScrollHandle::new();
1287 let (view, cx) = cx.add_window_view({
1288 let scroll_handle = scroll_handle.clone();
1289 move |_, _| DynamicScrollHarness {
1290 scroll_handle,
1291 menu: false,
1292 size,
1293 tabs: 4,
1294 selected_index: 0,
1295 }
1296 });
1297 draw(cx);
1298 draw(cx);
1299 view.update(cx, |view, cx| {
1300 view.tabs = 5;
1301 view.scroll_handle.scroll_to_item(4);
1302 cx.notify();
1303 });
1304 draw(cx);
1305 draw(cx);
1306 let bar = cx.debug_bounds("dynamic-bar").unwrap();
1307 let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
1308 assert_eq!(
1309 bar.right() - last_tab.right(),
1310 padding,
1311 "right padding for {size:?}"
1312 );
1313 scroll_handle.scroll_to_item(0);
1314 draw(cx);
1315 draw(cx);
1316 let first_tab = cx.debug_bounds("dynamic-tab-0").unwrap();
1317 assert_eq!(
1318 first_tab.left() - bar.left(),
1319 padding,
1320 "left padding for {size:?}"
1321 );
1322 assert_eq!(scroll_handle.children_count(), 5);
1323 }
1324 }
1325
1326 #[gpui::test]
1327 fn closing_an_unselected_trailing_tab_preserves_manual_scrolling(cx: &mut TestAppContext) {
1328 cx.update(crate::theme::init);
1329 let scroll_handle = ScrollHandle::new();
1330 let (view, cx) = cx.add_window_view({
1331 let scroll_handle = scroll_handle.clone();
1332 move |_, _| ManualScrollHarness {
1333 scroll_handle,
1334 tabs: 6,
1335 selected_index: 0,
1336 label: "old",
1337 top: px(0.),
1338 }
1339 });
1340
1341 draw(cx);
1342 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1343 draw(cx);
1344 assert_eq!(scroll_handle.offset().x, px(-100.));
1345
1346 view.update(cx, |view, cx| {
1347 view.tabs = 5;
1348 cx.notify();
1349 });
1350 draw(cx);
1351
1352 assert_eq!(scroll_handle.offset().x, px(-100.));
1353 }
1354
1355 #[gpui::test]
1356 fn changing_selection_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
1357 cx.update(crate::theme::init);
1358 let scroll_handle = ScrollHandle::new();
1359 let (view, cx) = cx.add_window_view({
1360 let scroll_handle = scroll_handle.clone();
1361 move |_, _| ManualScrollHarness {
1362 scroll_handle,
1363 tabs: 6,
1364 selected_index: 0,
1365 label: "old",
1366 top: px(0.),
1367 }
1368 });
1369
1370 draw(cx);
1371 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1372 draw(cx);
1373
1374 view.update(cx, |view, cx| {
1375 view.selected_index = 5;
1376 cx.notify();
1377 });
1378 draw(cx);
1379
1380 assert_eq!(scroll_handle.offset().x, px(-100.));
1381 }
1382
1383 #[gpui::test]
1384 fn changing_tab_labels_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
1385 cx.update(crate::theme::init);
1386 let scroll_handle = ScrollHandle::new();
1387 let (view, cx) = cx.add_window_view({
1388 let scroll_handle = scroll_handle.clone();
1389 move |_, _| ManualScrollHarness {
1390 scroll_handle,
1391 tabs: 6,
1392 selected_index: 0,
1393 label: "old",
1394 top: px(0.),
1395 }
1396 });
1397
1398 draw(cx);
1399 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1400 draw(cx);
1401
1402 view.update(cx, |view, cx| {
1403 view.label = "new";
1404 cx.notify();
1405 });
1406 draw(cx);
1407
1408 assert_eq!(scroll_handle.offset().x, px(-100.));
1409 }
1410
1411 #[gpui::test]
1412 fn moving_the_tab_bar_preserves_manual_scrolling(cx: &mut TestAppContext) {
1413 cx.update(crate::theme::init);
1414 let scroll_handle = ScrollHandle::new();
1415 let (view, cx) = cx.add_window_view({
1416 let scroll_handle = scroll_handle.clone();
1417 move |_, _| ManualScrollHarness {
1418 scroll_handle,
1419 tabs: 6,
1420 selected_index: 0,
1421 label: "old",
1422 top: px(0.),
1423 }
1424 });
1425
1426 draw(cx);
1427 scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1428 draw(cx);
1429 assert_eq!(scroll_handle.offset().x, px(-100.));
1430 let viewport_size = scroll_handle.bounds().size;
1431
1432 view.update(cx, |view, cx| {
1433 view.top = px(20.);
1434 cx.notify();
1435 });
1436 draw(cx);
1437
1438 assert_eq!(scroll_handle.bounds().size, viewport_size);
1439 assert_eq!(scroll_handle.offset().x, px(-100.));
1440 }
1441
1442 struct FlexHarness {
1443 variant: TabVariant,
1444 }
1445
1446 impl Render for FlexHarness {
1447 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1448 div()
1449 .w(px(200.))
1450 .debug_selector(|| "flex-bar".into())
1451 .child(
1452 TabBar::new("flex-tabs")
1453 .w_full()
1454 .with_variant(self.variant)
1455 .selected_index(0)
1456 .child(
1457 Tab::new()
1458 .flex_1()
1459 .label("A")
1460 .debug_selector(|| "flex-tab-0".into()),
1461 )
1462 .child(
1463 Tab::new()
1464 .flex_1()
1465 .label("B")
1466 .debug_selector(|| "flex-tab-1".into()),
1467 ),
1468 )
1469 }
1470 }
1471
1472 #[gpui::test]
1473 fn flex_tabs_share_the_available_width(cx: &mut TestAppContext) {
1474 cx.update(crate::theme::init);
1475 for variant in [
1476 TabVariant::Tab,
1477 TabVariant::Outline,
1478 TabVariant::Segmented,
1479 TabVariant::Pill,
1480 TabVariant::Underline,
1481 ] {
1482 let (_, cx) = cx.add_window_view(move |_, _| FlexHarness { variant });
1483 draw(cx);
1484 draw(cx);
1485
1486 let bar = cx.debug_bounds("flex-bar").unwrap();
1487 let first = cx.debug_bounds("flex-tab-0").unwrap();
1488 let second = cx.debug_bounds("flex-tab-1").unwrap();
1489 assert_eq!(first.size.width, second.size.width, "{variant:?}");
1490 assert_eq!(
1492 first.left() - bar.left(),
1493 bar.right() - second.right(),
1494 "{variant:?}"
1495 );
1496 assert!(
1497 bar.right() - second.right() <= px(4.),
1498 "{variant:?}: tabs end at {:?} but the bar ends at {:?}",
1499 second.right(),
1500 bar.right()
1501 );
1502 }
1503 }
1504
1505 struct ClickRevealHarness {
1506 group_handler: bool,
1507 scroll_handle: Option<ScrollHandle>,
1508 }
1509
1510 impl Render for ClickRevealHarness {
1511 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1512 div()
1514 .w(px(100.))
1515 .debug_selector(|| "click-bar".into())
1516 .child(
1517 TabBar::new("click-reveal-tabs")
1518 .w_full()
1519 .when_some(self.scroll_handle.as_ref(), |tabs, handle| {
1520 tabs.track_scroll(handle)
1521 })
1522 .children((0..5).map(|ix| {
1523 Tab::new()
1524 .w(px(60.))
1525 .label(format!("Tab {ix}"))
1526 .debug_selector(move || format!("click-tab-{ix}"))
1527 .on_click(|_, _, _| {})
1528 }))
1529 .when(self.group_handler, |tabs| tabs.on_click(|_, _, _| {})),
1530 )
1531 }
1532 }
1533
1534 fn click_reveal_harness(
1535 cx: &mut TestAppContext,
1536 group_handler: bool,
1537 scroll_handle: Option<ScrollHandle>,
1538 reduce_motion: bool,
1539 ) -> &mut gpui::VisualTestContext {
1540 cx.update(|cx| {
1541 crate::theme::init(cx);
1542 cx.set_reduce_motion(reduce_motion);
1543 });
1544 let (_, cx) = cx.add_window_view(move |_, _| ClickRevealHarness {
1545 group_handler,
1546 scroll_handle,
1547 });
1548 draw(cx);
1549 cx
1550 }
1551
1552 fn next_frame(cx: &mut gpui::VisualTestContext) {
1553 cx.executor()
1554 .advance_clock(std::time::Duration::from_millis(16));
1555 cx.update(|window, cx| window.simulate_next_frame(cx));
1556 draw(cx);
1557 }
1558
1559 #[gpui::test]
1560 fn clicking_a_clipped_tab_reveals_it_and_part_of_the_next(cx: &mut TestAppContext) {
1561 for group_handler in [false, true] {
1562 let cx = click_reveal_harness(cx, group_handler, None, true);
1563 let bar = cx.debug_bounds("click-bar").unwrap();
1564 let tab = cx.debug_bounds("click-tab-1").unwrap();
1565 assert!(tab.right() > bar.right(), "tab 1 starts clipped");
1566
1567 cx.simulate_click(tab.center(), Modifiers::default());
1568 draw(cx);
1569
1570 let tab = cx.debug_bounds("click-tab-1").unwrap();
1571 let next = cx.debug_bounds("click-tab-2").unwrap();
1572 assert!(tab.left() >= bar.left() && tab.right() <= bar.right());
1573 assert_eq!(bar.right() - next.left(), px(32.), "group: {group_handler}");
1574 }
1575 }
1576
1577 #[gpui::test]
1578 fn clicking_the_last_tab_reveals_the_end_of_the_bar(cx: &mut TestAppContext) {
1579 let scroll_handle = ScrollHandle::new();
1580 let cx = click_reveal_harness(cx, true, Some(scroll_handle.clone()), true);
1581 scroll_handle.set_offset(gpui::point(px(-160.), px(0.)));
1582 draw(cx);
1583
1584 let tab = cx.debug_bounds("click-tab-4").unwrap();
1585 cx.simulate_click(
1586 tab.origin + gpui::point(px(10.), px(10.)),
1587 Modifiers::default(),
1588 );
1589 draw(cx);
1590
1591 assert_eq!(scroll_handle.offset().x, -scroll_handle.max_offset().x);
1592 }
1593
1594 #[gpui::test]
1595 fn reveal_animates_and_yields_to_manual_scrolling(cx: &mut TestAppContext) {
1596 let scroll_handle = ScrollHandle::new();
1597 let cx = click_reveal_harness(cx, true, Some(scroll_handle.clone()), false);
1598
1599 let tab = cx.debug_bounds("click-tab-1").unwrap();
1600 cx.simulate_click(tab.center(), Modifiers::default());
1601 draw(cx);
1602 next_frame(cx);
1603 let moving = scroll_handle.offset().x;
1604 assert!(
1605 moving < px(0.) && moving > px(-52.),
1606 "mid-flight at {moving:?}"
1607 );
1608
1609 wheel(
1611 cx,
1612 scroll_handle.bounds().center(),
1613 gpui::ScrollDelta::Lines(point(0., 1.)),
1614 gpui::TouchPhase::Moved,
1615 );
1616 let manual = scroll_handle.offset().x;
1617 assert!(manual > moving);
1618 let after_wheel = cx.debug_bounds("click-tab-1").unwrap();
1619 for _ in 0..30 {
1620 next_frame(cx);
1621 }
1622 assert_eq!(scroll_handle.offset().x, manual);
1623 assert_eq!(cx.debug_bounds("click-tab-1").unwrap(), after_wheel);
1624
1625 cx.simulate_click(
1627 tab.center() - gpui::point(px(10.), px(0.)),
1628 Modifiers::default(),
1629 );
1630 for _ in 0..60 {
1631 next_frame(cx);
1632 }
1633 assert_eq!(scroll_handle.offset().x, px(-52.));
1634 }
1635
1636 struct WheelHarness {
1637 scroll_handle: Option<ScrollHandle>,
1638 variant: TabVariant,
1639 }
1640
1641 fn wheel_tabs(handle: Option<&ScrollHandle>, variant: TabVariant, tabs: usize) -> TabBar {
1642 TabBar::new("wheel-tabs")
1643 .w_full()
1644 .with_variant(variant)
1645 .when_some(handle, |tabs, handle| tabs.track_scroll(handle))
1646 .children((0..tabs).map(|ix| {
1647 Tab::new()
1648 .w(px(60.))
1649 .label(format!("Tab {ix}"))
1650 .debug_selector(move || format!("wheel-tab-{ix}"))
1651 }))
1652 }
1653
1654 impl Render for WheelHarness {
1655 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1656 div()
1657 .w(px(160.))
1658 .debug_selector(|| "wheel-bar".into())
1659 .child(wheel_tabs(self.scroll_handle.as_ref(), self.variant, 6))
1660 }
1661 }
1662
1663 fn wheel(
1664 cx: &mut gpui::VisualTestContext,
1665 position: gpui::Point<Pixels>,
1666 delta: gpui::ScrollDelta,
1667 touch_phase: gpui::TouchPhase,
1668 ) {
1669 cx.simulate_event(gpui::ScrollWheelEvent {
1670 position,
1671 delta,
1672 touch_phase,
1673 ..Default::default()
1674 });
1675 draw(cx);
1676 }
1677
1678 #[gpui::test]
1679 fn vertical_mouse_wheel_scrolls_each_variant_with_and_without_a_tracked_handle(
1680 cx: &mut TestAppContext,
1681 ) {
1682 cx.update(crate::theme::init);
1683 for variant in [
1684 TabVariant::Tab,
1685 TabVariant::Outline,
1686 TabVariant::Segmented,
1687 TabVariant::Pill,
1688 TabVariant::Underline,
1689 TabVariant::Folder,
1690 ] {
1691 for tracked in [false, true] {
1692 let scroll_handle = tracked.then(ScrollHandle::new);
1693 let (_, cx) = cx.add_window_view({
1694 let scroll_handle = scroll_handle.clone();
1695 move |_, _| WheelHarness {
1696 scroll_handle,
1697 variant,
1698 }
1699 });
1700 draw(cx);
1701 draw(cx);
1702 let bar = cx.debug_bounds("wheel-bar").unwrap();
1703 let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
1704 let last_before = cx.debug_bounds("wheel-tab-5").unwrap();
1705 assert!(
1706 last_before.left() > bar.right(),
1707 "{variant:?}, tracked: {tracked}"
1708 );
1709
1710 wheel(
1711 cx,
1712 first_before.center(),
1713 gpui::ScrollDelta::Lines(point(0., -100.)),
1714 gpui::TouchPhase::Moved,
1715 );
1716 let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
1717 let last_after = cx.debug_bounds("wheel-tab-5").unwrap();
1718 assert!(
1719 first_after.left() < first_before.left(),
1720 "{variant:?}, tracked: {tracked}, bar {bar:?}, first {first_before:?}, after {first_after:?}"
1721 );
1722 assert!(last_after.left() >= bar.left() && last_after.right() <= bar.right());
1723 if let Some(handle) = &scroll_handle {
1724 assert_eq!(handle.offset().x, -handle.max_offset().x);
1725 assert_eq!(handle.offset().y, px(0.));
1726 }
1727
1728 wheel(
1729 cx,
1730 first_before.center(),
1731 gpui::ScrollDelta::Lines(point(0., 100.)),
1732 gpui::TouchPhase::Moved,
1733 );
1734 assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
1735 if let Some(handle) = &scroll_handle {
1736 assert_eq!(handle.offset(), point(px(0.), px(0.)));
1737 }
1738 }
1739 }
1740 }
1741
1742 #[derive(Clone)]
1743 enum WheelParent {
1744 Div(ScrollHandle),
1745 List(gpui::ListState),
1746 Scrollable,
1747 }
1748
1749 impl WheelParent {
1750 fn offset(&self) -> Option<(usize, Pixels)> {
1751 match self {
1752 Self::Div(handle) => Some((0, -handle.offset().y)),
1753 Self::List(state) => {
1754 let offset = state.logical_scroll_top();
1755 Some((offset.item_ix, offset.offset_in_item))
1756 }
1757 Self::Scrollable => None,
1760 }
1761 }
1762 }
1763
1764 struct NestedWheelHarness {
1765 parent: WheelParent,
1766 scroll_handle: ScrollHandle,
1767 tabs: usize,
1768 }
1769
1770 impl Render for NestedWheelHarness {
1771 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1772 match &self.parent {
1773 WheelParent::Div(parent) => crate::v_flex()
1774 .id("wheel-parent")
1775 .w(px(160.))
1776 .h(px(100.))
1777 .overflow_y_scroll()
1778 .lock_scroll_axis()
1779 .track_scroll(parent)
1780 .child(div().w_full().h(px(60.)).flex_shrink_0().child(wheel_tabs(
1781 Some(&self.scroll_handle),
1782 TabVariant::Tab,
1783 self.tabs,
1784 )))
1785 .child(div().h(px(400.)).flex_shrink_0())
1786 .into_any_element(),
1787 WheelParent::Scrollable => crate::v_flex()
1788 .w(px(160.))
1789 .h(px(100.))
1790 .child(div().w_full().h(px(60.)).flex_shrink_0().child(wheel_tabs(
1791 Some(&self.scroll_handle),
1792 TabVariant::Tab,
1793 self.tabs,
1794 )))
1795 .child(div().h(px(400.)).flex_shrink_0())
1796 .overflow_y_scrollbar()
1797 .into_any_element(),
1798 WheelParent::List(parent) => {
1799 let handle = self.scroll_handle.clone();
1800 let tabs = self.tabs;
1801 div()
1802 .w(px(160.))
1803 .h(px(100.))
1804 .child(
1805 gpui::list(parent.clone(), move |ix, _, _| {
1806 if ix == 0 {
1807 div()
1808 .w_full()
1809 .h(px(60.))
1810 .child(wheel_tabs(Some(&handle), TabVariant::Tab, tabs))
1811 .into_any_element()
1812 } else {
1813 div().w_full().h(px(40.)).into_any_element()
1814 }
1815 })
1816 .w_full()
1817 .h_full(),
1818 )
1819 .into_any_element()
1820 }
1821 }
1822 }
1823 }
1824
1825 #[gpui::test]
1826 fn vertical_mouse_wheel_uses_tabs_before_scrolling_ancestors(cx: &mut TestAppContext) {
1827 cx.update(crate::theme::init);
1828 for parent_kind in 0..3 {
1829 for (tabs, at_end) in [(6, false), (6, true), (1, false)] {
1830 let scroll_handle = ScrollHandle::new();
1831 let parent = match parent_kind {
1832 0 => WheelParent::Div(ScrollHandle::new()),
1833 1 => WheelParent::List(
1834 gpui::ListState::new(10, gpui::ListAlignment::Top, px(0.)).measure_all(),
1835 ),
1836 _ => WheelParent::Scrollable,
1837 };
1838 let (_, cx) = cx.add_window_view({
1839 let scroll_handle = scroll_handle.clone();
1840 let parent = parent.clone();
1841 move |_, _| NestedWheelHarness {
1842 parent,
1843 scroll_handle,
1844 tabs,
1845 }
1846 });
1847 draw(cx);
1848 draw(cx);
1849 if let WheelParent::List(state) = &parent {
1850 assert!(state.max_offset_for_scrollbar().y > px(0.));
1851 }
1852 if at_end {
1853 scroll_handle.set_offset(point(-scroll_handle.max_offset().x, px(0.)));
1854 draw(cx);
1855 }
1856 let before_x = scroll_handle.offset().x;
1857 let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
1858 let position = scroll_handle.bounds().center();
1859 assert_eq!(parent.offset().unwrap_or_default(), (0, px(0.)));
1860 wheel(
1861 cx,
1862 position,
1863 gpui::ScrollDelta::Lines(point(0., -1.)),
1864 gpui::TouchPhase::Moved,
1865 );
1866 let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
1867 if tabs > 1 && !at_end {
1868 assert!(scroll_handle.offset().x < before_x);
1869 assert!(first_after.left() < first_before.left());
1870 assert_eq!(parent.offset().unwrap_or_default(), (0, px(0.)));
1871 assert_eq!(first_after.top(), first_before.top());
1872 } else {
1873 assert_eq!(scroll_handle.offset().x, before_x);
1874 if let Some(offset) = parent.offset() {
1875 assert_ne!(
1876 offset,
1877 (0, px(0.)),
1878 "parent {parent_kind}, tabs {tabs}, at_end {at_end}, before_x {before_x:?}, first_before {first_before:?}, after {first_after:?}"
1879 );
1880 }
1881 assert!(first_after.top() < first_before.top());
1882 }
1883 }
1884 }
1885 }
1886
1887 struct WheelLineHeightHarness {
1888 scroll_handle: ScrollHandle,
1889 font_size: Pixels,
1890 line_height: Pixels,
1891 }
1892
1893 impl Render for WheelLineHeightHarness {
1894 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1895 div()
1898 .w(px(160.))
1899 .h(px(64.))
1900 .text_size(self.font_size)
1901 .line_height(self.line_height)
1902 .child(wheel_tabs(Some(&self.scroll_handle), TabVariant::Tab, 6))
1903 }
1904 }
1905
1906 #[gpui::test]
1907 fn vertical_mouse_wheel_uses_the_scroll_viewports_inherited_line_height(
1908 cx: &mut TestAppContext,
1909 ) {
1910 cx.update(crate::theme::init);
1911 for (font_size, line_height) in [(px(12.), px(18.)), (px(24.), px(48.))] {
1912 let scroll_handle = ScrollHandle::new();
1913 let (_, cx) = cx.add_window_view({
1914 let scroll_handle = scroll_handle.clone();
1915 move |_, _| WheelLineHeightHarness {
1916 scroll_handle,
1917 font_size,
1918 line_height,
1919 }
1920 });
1921 draw(cx);
1922 draw(cx);
1923 let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
1924 assert!(scroll_handle.max_offset().x > line_height);
1925 assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
1926
1927 let position = first_before.center();
1930 wheel(
1931 cx,
1932 position,
1933 gpui::ScrollDelta::Lines(point(0., -1.)),
1934 gpui::TouchPhase::Moved,
1935 );
1936 assert_eq!(
1937 scroll_handle.offset(),
1938 point(-line_height, px(0.)),
1939 "font {font_size:?}, line height {line_height:?}"
1940 );
1941 let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
1942 assert_eq!(first_after.left() - first_before.left(), -line_height);
1943 assert_eq!(first_after.top(), first_before.top());
1944
1945 wheel(
1946 cx,
1947 position,
1948 gpui::ScrollDelta::Lines(point(0., 1.)),
1949 gpui::TouchPhase::Moved,
1950 );
1951 assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
1952 assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
1953 }
1954 }
1955
1956 struct WheelHitboxHarness {
1957 scroll_handle: ScrollHandle,
1958 occluded: bool,
1959 }
1960
1961 impl Render for WheelHitboxHarness {
1962 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1963 div()
1964 .relative()
1965 .w(px(240.))
1966 .h(px(64.))
1967 .child(
1968 wheel_tabs(Some(&self.scroll_handle), TabVariant::Tab, 6)
1969 .prefix(
1970 div()
1971 .w(px(40.))
1972 .h(px(32.))
1973 .debug_selector(|| "wheel-prefix".into())
1974 .child("P"),
1975 )
1976 .suffix(
1977 div()
1978 .w(px(40.))
1979 .h(px(32.))
1980 .debug_selector(|| "wheel-suffix".into())
1981 .child("S"),
1982 ),
1983 )
1984 .when(self.occluded, |this| {
1985 this.child(
1986 div()
1987 .absolute()
1988 .inset_0()
1989 .occlude()
1990 .debug_selector(|| "wheel-occluder".into()),
1991 )
1992 })
1993 }
1994 }
1995
1996 #[gpui::test]
1997 fn vertical_mouse_wheel_respects_the_viewport_and_occluding_surfaces(cx: &mut TestAppContext) {
1998 cx.update(crate::theme::init);
1999 let scroll_handle = ScrollHandle::new();
2000 let (view, cx) = cx.add_window_view({
2001 let scroll_handle = scroll_handle.clone();
2002 move |_, _| WheelHitboxHarness {
2003 scroll_handle,
2004 occluded: false,
2005 }
2006 });
2007 draw(cx);
2008 draw(cx);
2009 let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
2010 assert!(scroll_handle.max_offset().x > px(0.));
2011 assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
2012
2013 for selector in ["wheel-prefix", "wheel-suffix"] {
2014 let position = cx.debug_bounds(selector).unwrap().center();
2015 assert!(!scroll_handle.bounds().contains(&position));
2016 wheel(
2017 cx,
2018 position,
2019 gpui::ScrollDelta::Lines(point(0., -1.)),
2020 gpui::TouchPhase::Moved,
2021 );
2022 assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
2023 assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
2024 }
2025
2026 view.update(cx, |view, cx| {
2027 view.occluded = true;
2028 cx.notify();
2029 });
2030 draw(cx);
2031 let position = scroll_handle.bounds().center();
2032 let occluder = cx.debug_bounds("wheel-occluder").unwrap();
2033 assert!(occluder.size.width > px(0.) && occluder.size.height > px(0.));
2034 assert!(occluder.contains(&position));
2035 wheel(
2036 cx,
2037 position,
2038 gpui::ScrollDelta::Lines(point(0., -1.)),
2039 gpui::TouchPhase::Moved,
2040 );
2041 assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
2042 assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
2043
2044 view.update(cx, |view, cx| {
2045 view.occluded = false;
2046 cx.notify();
2047 });
2048 draw(cx);
2049 wheel(
2050 cx,
2051 position,
2052 gpui::ScrollDelta::Lines(point(0., -1.)),
2053 gpui::TouchPhase::Moved,
2054 );
2055 assert!(scroll_handle.offset().x < px(0.));
2056 let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
2057 assert!(first_after.left() < first_before.left());
2058 assert_eq!(first_after.top(), first_before.top());
2059 }
2060
2061 struct LateBar {
2064 shown: bool,
2065 width: Pixels,
2066 }
2067
2068 impl Render for LateBar {
2069 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2070 div()
2071 .w(self.width)
2072 .overflow_hidden()
2073 .when(self.shown, |this| {
2074 this.child(
2075 TabBar::new("late-tabs")
2076 .underline()
2077 .selected_index(0)
2078 .child(Tab::new().label("First"))
2079 .child(Tab::new().label("Second")),
2080 )
2081 })
2082 }
2083 }
2084
2085 fn show_late_bar(cx: &mut TestAppContext, width: Pixels) -> &mut gpui::VisualTestContext {
2088 cx.update(crate::theme::init);
2089 let (view, cx) = cx.add_window_view(move |_, _| LateBar {
2090 shown: false,
2091 width,
2092 });
2093 draw(cx);
2094 view.update(cx, |bar, cx| {
2095 bar.shown = true;
2096 cx.notify();
2097 });
2098 draw(cx);
2099 cx
2100 }
2101
2102 fn painted_indicators(cx: &mut gpui::VisualTestContext) -> Vec<Bounds<Pixels>> {
2104 cx.update(|window, cx| {
2105 let primary: Background = cx.theme().tokens.primary.into();
2106 let scale_factor = window.scale_factor();
2107 window
2108 .painted_quads()
2109 .into_iter()
2110 .filter(|quad| quad.background == primary)
2111 .map(|quad| quad.bounds.map(|value| px(value.0 / scale_factor)))
2112 .filter(|bounds| bounds.size.height == px(2.))
2113 .collect()
2114 })
2115 }
2116
2117 #[gpui::test]
2121 fn a_bar_shown_later_draws_its_indicator_on_the_next_frame(cx: &mut TestAppContext) {
2122 let cx = show_late_bar(cx, px(300.));
2123 next_frame(cx);
2124 let indicators = painted_indicators(cx);
2125 assert_eq!(indicators.len(), 1, "{indicators:?}");
2126 assert!(indicators[0].size.width > px(0.), "{indicators:?}");
2127 assert!(indicators[0].right() <= px(150.), "{indicators:?}");
2128 assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0);
2129 }
2130
2131 #[gpui::test]
2133 fn a_bar_with_no_width_asks_for_no_frames(cx: &mut TestAppContext) {
2134 let cx = show_late_bar(cx, px(0.));
2135 next_frame(cx);
2136 assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0);
2137 }
2138}