use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, ElementId, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::ActiveTheme as _; /// The change reporter, fired with the next selected tab by pointer and keys. type ChangeHandler = Rc; type TabsFn = Box Vec>; /// One tab of a [`Tabs`] strip. pub struct Tab { label: SharedString, icon: Option, disabled: bool, } impl Tab { /// Creates a tab with this label. pub fn new(label: L) -> Self where L: Into, { Self { label: label.into(), icon: None, disabled: false, } } /// Shows an icon leading the label. pub fn icon(mut self, role: IconRole) -> Self { self.icon = Some(role); self } /// Renders the tab dimmed; clicks and keyboard skip it. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } } /// A tab strip. #[derive(IntoElement)] pub struct Tabs { id: ElementId, selected: usize, a11y_label: Option, on_change: 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, a11y_label: None, on_change: None, tabs: None, } } /// Sets the current selection. pub fn selected(mut self, selected: usize) -> Self { self.selected = selected; 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 } /// 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 } } struct TabsChrome { foreground: Hsla, muted: Hsla, muted_foreground: Hsla, primary: Hsla, border: Hsla, radius_sm: Pixels, icon_size: Pixels, text_size: Pixels, /// The strip's selected index; re-selecting it reports nothing. selected: usize, } impl RenderOnce for Tabs { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let chrome = { let theme = cx.theme(); TabsChrome { foreground: theme.colors.foreground, muted: theme.colors.muted, muted_foreground: theme.colors.muted_foreground, primary: theme.ramps.primary.base, border: theme.colors.border, radius_sm: theme.radius.sm, icon_size: theme.sizes.medium.icon_size, text_size: theme.sizes.medium.text_size, selected: self.selected, } }; 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(); // Keyboard focus rides the strip across frames. let focus_handle = window.with_element_namespace(self.id.clone(), |window| { window.use_keyed_state("tabs-focus", cx, |_, cx| cx.focus_handle()) }); let focus = focus_handle.read(cx).clone(); let label = self .a11y_label .clone() .unwrap_or_else(|| SharedString::from(self.id.to_string())); let strip: Vec = tabs .iter() .enumerate() .map(|(index, tab)| { render_tab( tab, index, self.selected == index, &chrome, &self.on_change, &focus, ) }) .collect(); div() .id(self.id) .flex() .gap_1() .border_b_1() .border_color(chrome.border) .text_size(chrome.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, selected, true), "left" => adjacent(&selectable, 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(); } }) .children(strip) } } fn render_tab( tab: &Tab, index: usize, selected: bool, chrome: &TabsChrome, on_change: &Option, focus: &gpui::FocusHandle, ) -> AnyElement { let label_color = if tab.disabled { chrome.muted_foreground } else if selected { chrome.primary } else { chrome.foreground }; let mut strip_tab = div() .id(("tab", index)) .flex() .items_center() .gap_1p5() .px_2() .py_1() .rounded_tl(chrome.radius_sm) .rounded_tr(chrome.radius_sm) .role(Role::Tab) .aria_selected(selected) .text_color(label_color) .when(selected, |strip_tab| { // The underline sits on the strip's hairline, marking the active // tab the way a native tab bar does. strip_tab.border_b_2().border_color(chrome.primary) }) .when(!tab.disabled && !selected, |strip_tab| { strip_tab.hover(|strip_tab| strip_tab.bg(chrome.muted)) }) .child( div() .flex_none() .pb_0p5() .when_some(tab.icon, |strip_tab, role| { strip_tab.child(icon(role).size(chrome.icon_size * 0.75).color(label_color)) }), ) .child(strip_tab_label(tab.label.clone(), selected, chrome)); if !tab.disabled { let selected_index = chrome.selected; 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); } } }); } strip_tab.into_any_element() } fn strip_tab_label(label: SharedString, selected: bool, chrome: &TabsChrome) -> AnyElement { div() .text_color(if selected { chrome.primary } else { chrome.foreground }) .child(label) .into_any_element() } /// The next selectable tab in the direction. fn adjacent(selectable: &[usize], selected: usize, forward: bool) -> Option { if forward { selectable.iter().copied().find(|index| *index > selected) } else { selectable .iter() .copied() .rev() .find(|index| *index < selected) } }