use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, }; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::common::Sizable as _; use gpui_native_core::spinner::Spinner; use gpui_native_core::tooltip::Tooltip; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_utils::adjacent_selectable; /// The change reporter, fired with the next selected tab by pointer and keys. type ChangeHandler = Rc; /// The close reporter, fired with the tab's index by its close control. type CloseHandler = Rc; /// Decorates the rendered tab, e.g. to attach a drag payload. type TabWrap = Box) -> AnyElement>; type TabsFn = Box Vec>; /// The visual language of a [`Tabs`] strip. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TabsVariant { #[default] Underline, Pill, } /// One tab of a [`Tabs`] strip. pub struct Tab { label: SharedString, icon: Option, lead: Option, busy: bool, disabled: bool, closeable: bool, wrap: Option, } impl Tab { /// Creates a tab with this label. pub fn new(label: L) -> Self where L: Into, { Self { label: label.into(), icon: None, lead: None, busy: false, disabled: false, closeable: false, wrap: None, } } /// Shows an icon leading the label. pub fn icon(mut self, role: IconRole) -> Self { self.icon = Some(role); self } /// Sets the leading element. pub fn lead(mut self, lead: E) -> Self where E: IntoElement + 'static, { self.lead = Some(lead.into_any_element()); self } /// Shows the loading slot. pub fn busy(mut self, busy: bool) -> Self { self.busy = busy; self } /// Renders the tab dimmed; clicks and keyboard skip it. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } /// Shows a close control that reports the tab through [`Tabs::on_close`]. pub fn closeable(mut self, closeable: bool) -> Self { self.closeable = closeable; self } /// Hands the rendered tab through `wrap`, e.g. to attach a drag payload. pub fn wrap(mut self, wrap: F) -> Self where F: FnOnce(Stateful
) -> AnyElement + 'static, { self.wrap = Some(Box::new(wrap)); self } } /// A tab strip. #[derive(IntoElement)] pub struct Tabs { id: ElementId, selected: usize, variant: TabsVariant, a11y_label: Option, on_change: Option, on_close: Option, tabs: Option, } impl Tabs { /// Creates a strip selecting the first tab. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), selected: 0, variant: TabsVariant::default(), a11y_label: None, on_change: None, on_close: None, tabs: None, } } /// Sets the current selection. pub fn selected(mut self, selected: usize) -> Self { self.selected = selected; self } /// Sets the strip's visual language. pub fn variant(mut self, variant: TabsVariant) -> Self { self.variant = variant; self } /// Reports the next selected tab, from clicks and keys alike. pub fn on_change(mut self, handler: F) -> Self where F: Fn(usize, &mut Window, &mut App) + 'static, { self.on_change = Some(Rc::new(handler)); self } /// Reports a closed tab by index. pub fn on_close(mut self, handler: F) -> Self where F: Fn(usize, &mut Window, &mut App) + 'static, { self.on_close = Some(Rc::new(handler)); self } /// Sets the tabs, built fresh each render. pub fn tabs(mut self, tabs: F) -> Self where F: Fn(&mut Window, &mut App) -> Vec + 'static, { self.tabs = Some(Box::new(tabs)); self } /// Overrides the accessibility label, which defaults to the id. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl RenderOnce for Tabs { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let tabs = match self.tabs { Some(build) => build(window, cx), None => Vec::new(), }; let selectable: Vec = tabs .iter() .enumerate() .filter(|(_, tab)| !tab.disabled) .map(|(index, _)| index) .collect(); let focus = gpui_native_utils::focus_handle(&self.id, window, cx); let theme = cx.theme(); let label = self .a11y_label .clone() .unwrap_or_else(|| SharedString::from(self.id.to_string())); let strip: Vec = tabs .into_iter() .enumerate() .map(|(index, tab)| { render_tab( &self.id, cx, tab, index, self.selected == index, self.selected, self.variant, &self.on_change, &self.on_close, &focus, ) }) .collect(); let mut strip_root = div() .id(self.id.clone()) .flex() .text_size(theme.sizes.medium.text_size) .track_focus(&focus) .focusable() .tab_stop(true) .role(Role::TabList) .aria_label(label) .on_key_down({ let selected = self.selected; let on_change = self.on_change.clone(); move |event: &KeyDownEvent, window, cx| { let target = match event.keystroke.key.as_str() { "right" => adjacent_selectable(&selectable, Some(selected), true), "left" => adjacent_selectable(&selectable, Some(selected), false), "home" => selectable.first().copied(), "end" => selectable.last().copied(), _ => return, }; if let Some(next) = target && next != selected && let Some(on_change) = &on_change { on_change(next, window, cx); } cx.stop_propagation(); } }); strip_root = match self.variant { TabsVariant::Underline => strip_root .gap_1() .border_b_1() .border_color(theme.colors.border), TabsVariant::Pill => strip_root.items_center().gap_1(), }; strip_root.children(strip) } } #[allow(clippy::too_many_arguments)] fn render_tab( tabs_id: &ElementId, cx: &App, tab: Tab, index: usize, selected: bool, selected_index: usize, variant: TabsVariant, on_change: &Option, on_close: &Option, focus: &gpui::FocusHandle, ) -> AnyElement { let mut tab = tab; let theme = cx.theme(); let metrics = theme.sizes.resolve(Size::default()); let small = theme.sizes.small; let label_color = match variant { TabsVariant::Underline => { if tab.disabled { theme.colors.muted_foreground } else if selected { theme.ramps.primary.base } else { theme.colors.foreground } } TabsVariant::Pill => { if tab.disabled { theme.colors.muted_foreground } else { theme.colors.foreground } } }; let mut strip_tab = div() .id(("tab", index)) .debug_selector({ let id = format!("{tabs_id}-tab-{}", tab.label); move || id }) .flex() .items_center() .gap_1p5() .role(Role::Tab) .aria_selected(selected) .text_color(label_color) .when(!tab.disabled && !selected, |strip_tab| { strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted)) }); strip_tab = match variant { TabsVariant::Underline => strip_tab .px_2() .py_1() .rounded_tl(theme.radius.sm) .rounded_tr(theme.radius.sm) .border_b_2() .border_color(gpui::transparent_black()) .when(selected, |strip_tab| { strip_tab.border_color(theme.ramps.primary.base) }), TabsVariant::Pill => strip_tab .h(small.control_height + metrics.plate_inset * 2.0) .px(metrics.dense_padding_x) .pr(metrics.plate_inset) .rounded(theme.radius.control) .when(selected, |strip_tab| { strip_tab .bg(theme.colors.muted) .hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover)) }), }; let lead = tab.lead.take(); strip_tab = match lead { Some(lead) => strip_tab.child(lead), None => match variant { TabsVariant::Underline => strip_tab.child(div().flex_none().pb_0p5().when_some( tab.icon, |slot, role| { slot.child(icon(role).size(metrics.dense_icon_size).color(label_color)) }, )), TabsVariant::Pill => strip_tab.when_some(tab.icon, |strip_tab, role| { strip_tab.child(icon(role).size(metrics.dense_icon_size).color(label_color)) }), }, }; strip_tab = strip_tab.child(tab.label.clone()); if variant == TabsVariant::Pill { strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5)); } if tab.busy && !tab.disabled { strip_tab = strip_tab.child( Spinner::new(("tab-busy", index)) .small() .accessibility_label("Loading"), ); } if tab.closeable && on_close.is_some() && !tab.disabled { strip_tab = strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close)); } if !tab.disabled { strip_tab = strip_tab.cursor_pointer().on_click({ let on_change = on_change.clone(); let focus = focus.clone(); move |_, window, cx| { window.focus(&focus, cx); if index != selected_index && let Some(on_change) = &on_change { on_change(index, window, cx); } } }); } match tab.wrap { Some(wrap) => wrap(strip_tab), None => strip_tab.into_any_element(), } } fn render_tab_close( tabs_id: &ElementId, tab: &Tab, index: usize, on_close: &Option, ) -> AnyElement { Button::new(("tab-close", index)) .icon(IconRole::Close) .variant(ButtonVariant::Ghost) .small() .tab_stop(false) .debug_selector({ let id = format!("{tabs_id}-close-{}", tab.label); move || id.clone() }) .tooltip(Tooltip::text("Close")) .accessibility_label(SharedString::from(format!("Close {}", tab.label))) .on_click({ let on_close = on_close.clone(); move |_, window, cx| { cx.stop_propagation(); if let Some(on_close) = &on_close { on_close(index, window, cx); } } }) .into_any_element() }