1use super::button::{ButtonFocusRing, ButtonRipple};
21use super::navigation::{NavBadge, badged_icon, ripple_corners, type_role};
22use super::roving::{self, Change, Orientation, Roving};
23use super::{InteractionPreview, large_badge, small_badge};
24use crate::color::ColorRole;
25use crate::icons::Icon;
26use crate::motion::{self, MotionToken};
27use crate::theme::{blend, color};
28use crate::tokens::{
29 state,
30 typography::{TypeRole, rem},
31};
32use gpui::{prelude::FluentBuilder as _, *};
33use std::{collections::BTreeMap, rc::Rc};
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub enum TabsVariant {
38 #[default]
39 Primary,
40 Secondary,
41}
42
43#[derive(Clone)]
46pub struct TabItem {
47 value: SharedString,
48 label: SharedString,
49 icon: Option<Icon>,
50 show_label: bool,
51 badge: NavBadge,
52 disabled: bool,
53 preview: Option<InteractionPreview>,
54}
55
56impl TabItem {
57 pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
58 Self {
59 value: value.into(),
60 label: label.into(),
61 icon: None,
62 show_label: true,
63 badge: NavBadge::None,
64 disabled: false,
65 preview: None,
66 }
67 }
68 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
69 self.icon = Some(icon.into());
70 self
71 }
72 pub fn icon_only(mut self) -> Self {
74 self.show_label = false;
75 self
76 }
77 pub fn badge(mut self, badge: NavBadge) -> Self {
79 self.badge = badge;
80 self
81 }
82 pub fn disabled(mut self, disabled: bool) -> Self {
83 self.disabled = disabled;
84 self
85 }
86 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
90 self.preview = Some(state);
91 self
92 }
93 pub fn value(&self) -> &SharedString {
94 &self.value
95 }
96 pub fn label(&self) -> &SharedString {
97 &self.label
98 }
99 fn is_disabled(&self) -> bool {
100 self.disabled || self.preview.is_some_and(InteractionPreview::is_disabled)
101 }
102}
103
104#[derive(IntoElement)]
106pub struct MaterialTabs {
107 id: ElementId,
108 label: SharedString,
109 variant: TabsVariant,
110 scrollable: bool,
111 auto_activate: bool,
112 divider: bool,
113 items: Vec<TabItem>,
114 selected: Option<SharedString>,
115 on_change: Option<Change>,
116}
117
118impl MaterialTabs {
119 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
121 Self {
122 id: id.into(),
123 label: label.into(),
124 variant: TabsVariant::Primary,
125 scrollable: false,
126 auto_activate: false,
127 divider: true,
128 items: Vec::new(),
129 selected: None,
130 on_change: None,
131 }
132 }
133 pub fn variant(mut self, variant: TabsVariant) -> Self {
134 self.variant = variant;
135 self
136 }
137 pub fn scrollable(mut self, scrollable: bool) -> Self {
139 self.scrollable = scrollable;
140 self
141 }
142 pub fn auto_activate(mut self, auto: bool) -> Self {
145 self.auto_activate = auto;
146 self
147 }
148 pub fn divider(mut self, divider: bool) -> Self {
151 self.divider = divider;
152 self
153 }
154 pub fn items(mut self, items: impl IntoIterator<Item = TabItem>) -> Self {
156 self.items = items.into_iter().collect();
157 self
158 }
159 pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
160 self.selected = Some(value.into());
161 self
162 }
163 pub fn on_change(
164 mut self,
165 handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
166 ) -> Self {
167 self.on_change = Some(Rc::new(handler));
168 self
169 }
170}
171
172const SCROLL_INSET: f32 = 52.;
174const TAB_PADDING: f32 = 16.;
175
176pub(crate) fn tabs_height(variant: TabsVariant, items: &[TabItem]) -> f32 {
178 let stacked = variant == TabsVariant::Primary
179 && items.iter().any(|item| item.icon.is_some())
180 && items.iter().any(|item| item.show_label);
181 if stacked { 64. } else { 48. }
182}
183
184pub(crate) const fn indicator_height(variant: TabsVariant) -> f32 {
186 match variant {
187 TabsVariant::Primary => 3.,
188 TabsVariant::Secondary => 2.,
189 }
190}
191
192#[derive(Clone, Copy, Debug, PartialEq)]
194pub(crate) struct TabPaint {
195 pub content: Rgba,
196 pub hover: Rgba,
197 pub press: Rgba,
198}
199
200pub(crate) fn tab_paint(
201 variant: TabsVariant,
202 selected: bool,
203 fade: f32,
204 disabled: bool,
205) -> TabPaint {
206 if disabled {
207 return TabPaint {
208 content: state::disabled(color(ColorRole::ON_SURFACE)),
209 hover: Rgba::default(),
210 press: Rgba::default(),
211 };
212 }
213 let active = match variant {
214 TabsVariant::Primary => color(ColorRole::PRIMARY),
215 TabsVariant::Secondary => color(ColorRole::ON_SURFACE),
216 };
217 let layer = if variant == TabsVariant::Primary && selected {
218 color(ColorRole::PRIMARY)
219 } else {
220 color(ColorRole::ON_SURFACE)
221 };
222 TabPaint {
223 content: blend(
224 color(ColorRole::ON_SURFACE_VARIANT),
225 active,
226 fade.clamp(0., 1.),
227 ),
228 hover: layer.opacity(state::HOVER),
229 press: layer.opacity(state::PRESS),
230 }
231}
232
233#[derive(Default)]
236struct TabGeometry {
237 tabs: Vec<(f32, f32)>,
238 contents: Vec<(f32, f32)>,
239 probed: Vec<(f32, f32)>,
241}
242
243#[cfg(test)]
244thread_local! {
245 pub(crate) static INDICATOR: std::cell::Cell<Option<(f32, f32)>> =
246 const { std::cell::Cell::new(None) };
247}
248
249impl RenderOnce for MaterialTabs {
250 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
251 let variant = self.variant;
252 let primary = variant == TabsVariant::Primary;
253 let count = self.items.len();
254 let height = tabs_height(variant, &self.items);
255 let stacked = height > 48.;
256 let scrollable = self.scrollable;
257
258 let handles_state = window.use_keyed_state((self.id.clone(), "tab-focus"), cx, |_, _| {
259 BTreeMap::<SharedString, FocusHandle>::new()
260 });
261 let handles: Rc<[FocusHandle]> = handles_state
262 .update(cx, |handles, cx| {
263 roving::sync_handles(
264 handles,
265 &self.items,
266 |item| &item.value,
267 || cx.focus_handle(),
268 )
269 })
270 .into();
271 let scroll = window
272 .use_keyed_state((self.id.clone(), "tab-scroll"), cx, |_, _| {
273 ScrollHandle::new()
274 })
275 .read(cx)
276 .clone();
277 let geometry = window.use_keyed_state((self.id.clone(), "tab-geometry"), cx, |_, _| {
278 TabGeometry::default()
279 });
280 geometry.update(cx, |geometry, _| {
281 geometry.probed.clear();
282 geometry.probed.resize(count, (0., 0.));
283 });
284
285 let selected = self
286 .items
287 .iter()
288 .position(|item| Some(&item.value) == self.selected.as_ref());
289 let specimen = self.items.iter().any(|item| item.preview.is_some());
290 let skip: Rc<[bool]> = self.items.iter().map(TabItem::is_disabled).collect();
291 let select = self
292 .on_change
293 .clone()
294 .filter(|_| self.auto_activate)
295 .map(|on_change| {
296 roving::select_value(self.items.iter().map(|item| item.value.clone()), on_change)
297 });
298 let roving = Roving::new(handles.clone(), Orientation::Row)
299 .skip(skip)
300 .selected(selected)
301 .select_on_focus(select)
302 .reveal(scrollable.then(|| scroll.clone()));
303 let entry = if specimen {
304 None
305 } else {
306 roving.tab_stop(window)
307 };
308
309 let measured = {
311 let geometry = geometry.read(cx);
312 selected
313 .filter(|_| geometry.tabs.len() == count && geometry.contents.len() == count)
314 .map(|index| {
315 if primary {
316 geometry.contents[index]
317 } else {
318 geometry.tabs[index]
319 }
320 })
321 };
322 let rem_size = window.rem_size();
323 let inset = if scrollable {
324 rem(SCROLL_INSET).to_pixels(rem_size)
325 } else {
326 px(0.)
327 };
328 let thickness = rem(indicator_height(variant));
329 let indicator_bar = move || {
330 div()
331 .absolute()
332 .bottom_0()
333 .h(thickness)
334 .bg(color(ColorRole::PRIMARY))
335 .when(primary, |bar| bar.rounded_t(thickness))
336 };
337 let sliding = measured.map(|(left, width)| {
338 let target = Bounds::new(point(inset + px(left), px(0.)), size(px(width), px(0.)));
339 let at = motion::token_transition(
340 (self.id.clone(), "tab-indicator"),
341 target,
342 MotionToken::DefaultSpatial,
343 window,
344 cx,
345 );
346 #[cfg(test)]
347 INDICATOR
348 .with(|cell| cell.set(Some((f32::from(at.origin.x), f32::from(at.size.width)))));
349 indicator_bar().left(at.origin.x).w(at.size.width)
350 });
351 let fallback = sliding.is_none();
352
353 let mut lane = div()
354 .on_children_prepainted({
355 let geometry = geometry.clone();
356 move |bounds, window, cx| {
357 let Some(first) = bounds.first().map(|b| f32::from(b.left())) else {
358 return;
359 };
360 let tabs: Vec<(f32, f32)> = bounds
361 .iter()
362 .take(count)
363 .map(|b| (f32::from(b.left()) - first, f32::from(b.size.width)))
364 .collect();
365 let changed = geometry.update(cx, |geometry, _| {
366 let contents: Vec<(f32, f32)> = geometry
367 .probed
368 .iter()
369 .map(|(left, width)| (left - first, *width))
370 .collect();
371 let changed = geometry.tabs != tabs || geometry.contents != contents;
372 if changed {
373 geometry.tabs = tabs;
374 geometry.contents = contents;
375 }
376 changed
377 });
378 if changed {
379 window.defer(cx, |window, _| window.refresh());
380 }
381 }
382 })
383 .id((self.id.clone(), "tab-lane"))
384 .relative()
385 .w_full()
386 .h_full()
387 .flex()
388 .flex_row()
389 .when(scrollable, |lane| {
390 lane.overflow_x_scroll()
391 .track_scroll(&scroll)
392 .pl(rem(SCROLL_INSET))
393 });
394
395 for (index, item) in self.items.into_iter().enumerate() {
396 let is_selected = selected == Some(index);
397 let preview = item.preview;
398 let disabled = item.is_disabled();
399 let enabled = !disabled;
400 let tab_id = ElementId::NamedChild(self.id.clone().into(), item.value.clone());
401 let fade = motion::token_value(
402 (tab_id.clone(), "tab-fade"),
403 if is_selected { 1. } else { 0. },
404 MotionToken::FastEffects,
405 window,
406 cx,
407 );
408 let paint = tab_paint(variant, is_selected, fade, disabled);
409 let focus = handles[index]
410 .clone()
411 .tab_stop(entry == Some(index) && enabled);
412
413 let label = item.show_label.then(|| {
414 div()
415 .min_w_0()
416 .whitespace_nowrap()
417 .text_ellipsis()
418 .text_color(paint.content)
419 .map(|label| type_role(label, TypeRole::TitleSmall))
420 .child(item.label.clone())
421 });
422 let icon = item
423 .icon
424 .clone()
425 .map(|icon| badged_icon(icon.color(paint.content), &item.badge));
426 let inline_badge = match (&item.badge, item.icon.is_some()) {
427 (NavBadge::Small, false) => Some(small_badge().into_any_element()),
428 (NavBadge::Large(text), false) => {
429 Some(large_badge("tab-badge", text.clone()).into_any_element())
430 }
431 _ => None,
432 };
433 let probe_geometry = geometry.clone();
434 let probe = canvas(
435 move |bounds, _, cx| {
436 probe_geometry.update(cx, |geometry, _| {
437 if let Some(slot) = geometry.probed.get_mut(index) {
438 *slot = (f32::from(bounds.left()), f32::from(bounds.size.width));
439 }
440 })
441 },
442 |_, _, _, _| {},
443 )
444 .absolute()
445 .inset_0();
446 let content = div()
447 .relative()
448 .h_full()
449 .min_w_0()
450 .flex()
451 .items_center()
452 .justify_center()
453 .map(|content| {
454 if stacked {
455 content.flex_col().gap(rem(2.))
456 } else {
457 content.flex_row().gap(rem(8.))
458 }
459 })
460 .children(icon)
461 .children(label)
462 .children(inline_badge)
463 .child(probe)
464 .when(fallback && primary && is_selected, |content| {
465 content.child(indicator_bar().left_0().right_0())
466 });
467
468 let click_focus = focus.clone();
469 let on_change = self.on_change.clone();
470 let value = item.value.clone();
471 let reveal = scrollable.then(|| scroll.clone());
472 let tab = gpui_base::Tab::new(tab_id.clone())
473 .selected(is_selected)
474 .disabled(disabled)
475 .accessibility_label(item.label.clone())
476 .set_position(index + 1, count)
477 .track_focus(&focus)
478 .relative()
479 .h_full()
480 .px(rem(TAB_PADDING))
481 .map(|tab| {
482 if scrollable {
483 tab.flex_shrink_0()
484 } else {
485 tab.flex_1().min_w_0()
486 }
487 })
488 .map(|tab| match preview {
489 _ if disabled => tab,
490 None => tab
491 .cursor_pointer()
492 .hover(move |style| style.bg(paint.hover))
493 .active(move |style| style.bg(paint.press)),
494 Some(InteractionPreview::Hovered) => tab.bg(paint.hover),
495 Some(InteractionPreview::Pressed) => tab.bg(paint.press),
496 Some(_) => tab,
497 })
498 .when(cfg!(test), |tab| {
499 tab.debug_selector(move || format!("material-tab-{index}"))
500 })
501 .child(content)
502 .when(fallback && !primary && is_selected, |tab| {
503 tab.child(indicator_bar().left_0().right_0())
504 })
505 .when(preview.is_none(), |tab| {
506 tab.on_click(move |_, window, cx| {
507 click_focus.focus(window, cx);
508 if let Some(scroll) = &reveal {
509 scroll.scroll_to_item(index);
510 }
511 if !is_selected && let Some(on_change) = &on_change {
512 on_change(&value, window, cx);
513 }
514 })
515 });
516 let ink = if primary && is_selected {
517 color(ColorRole::PRIMARY)
518 } else {
519 color(ColorRole::ON_SURFACE)
520 };
521 let ripple = ButtonRipple::new(
522 tab,
523 (tab_id, "tab-ripple").into(),
524 preview.is_none(),
525 enabled,
526 ripple_corners(px(0.)),
527 Edges::all(px(0.).into()),
528 ink,
529 window,
530 cx,
531 );
532 let ring = ButtonFocusRing::with_corners(
533 ripple,
534 focus,
535 enabled,
536 Corners::all(rem(8.).to_pixels(rem_size)),
537 )
538 .inward()
539 .forced(preview == Some(InteractionPreview::Focused));
540 lane = lane.child(
541 div()
542 .h_full()
543 .flex()
544 .map(|slot| {
545 if scrollable {
546 slot.flex_shrink_0()
547 } else {
548 slot.flex_1().min_w_0()
549 }
550 })
551 .child(ring),
552 );
553 }
554 let lane = lane.children(sliding);
555 let list = gpui_base::Tabs::new(self.id)
556 .aria_label(self.label)
557 .relative()
558 .w_full()
559 .h(rem(height))
560 .bg(color(ColorRole::SURFACE))
561 .when(self.divider, |list| {
563 list.child(
564 div()
565 .absolute()
566 .left_0()
567 .right_0()
568 .bottom_0()
569 .h(px(1.))
570 .bg(color(ColorRole::OUTLINE_VARIANT))
571 .when(cfg!(test), |line| {
572 line.debug_selector(|| "tabs-divider".into())
573 }),
574 )
575 })
576 .child(lane);
577 roving.install(list)
578 }
579}
580
581#[cfg(test)]
582#[path = "tabs_tests.rs"]
583mod tests;