use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use crate::{ forms::{Choice, Pick, step}, layout::{measure_width, text_width}, motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide}, primitives::{Icon, Tooltip, tab_stop}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, }; /// Where a tab strip sits against its panel. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TabPlacement { #[default] Top, Left, Bottom, } type OnValue = Rc; type OnContext = Rc, &mut Window, &mut App)>; /// A strip of tabs and the chosen tab's panel. A line slides to the chosen tab; arrows move it. #[derive(IntoElement)] pub struct Tabs { id: ElementId, tabs: Vec, selected: SharedString, placement: TabPlacement, panel: Option, on_change: Option, on_context: Option, } impl Tabs { pub fn new( id: impl Into, tabs: impl IntoIterator, selected: impl Into, ) -> Self { Self { id: id.into(), tabs: tabs.into_iter().collect(), selected: selected.into(), placement: TabPlacement::default(), panel: None, on_change: None, on_context: None, } } pub fn placement(mut self, placement: TabPlacement) -> Self { self.placement = placement; self } /// The chosen tab's content. pub fn panel(mut self, panel: impl IntoElement) -> Self { self.panel = Some(panel.into_any_element()); self } pub fn on_change( mut self, handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { self.on_change = Some(Rc::new(handler)); self } } impl Tabs { /// A right click on a tab: its value and the pointer. pub fn on_context( mut self, handler: impl Fn(&SharedString, Point, &mut Window, &mut App) + 'static, ) -> Self { self.on_context = Some(Rc::new(handler)); self } } impl RenderOnce for Tabs { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let values: Vec = self.tabs.iter().map(|tab| tab.value.clone()).collect(); let chosen = values.iter().position(|value| *value == self.selected); let placement = self.placement; let axis = match placement { TabPlacement::Left => Axis::Vertical, TabPlacement::Top | TabPlacement::Bottom => Axis::Horizontal, }; let (state, marker) = slide( (self.id.clone(), "slide"), &values, &self.selected, window, cx, ); let turn = motion::changes( (self.id.clone(), "panel"), self.selected.clone(), window, cx, ); let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); let focused = focus.is_focused(window); let tabs = Rc::new(self.tabs); let pick: Pick = { let (id, tabs, on_change) = (self.id.clone(), tabs.clone(), self.on_change); Rc::new(move |ix, window, cx| { let value = &tabs[ix].value; log::info!("tabs {id:?}: {value}"); if let Some(on_change) = &on_change { on_change(value, window, cx); } }) }; // Last frame's strip width decides icons only. let strip_width = window.use_keyed_state((self.id.clone(), "strip-width"), cx, |_, _| Pixels::ZERO); let shown = *strip_width.read(cx); let compact = axis == Axis::Horizontal && tabs.iter().all(|tab| tab.icon.is_some()) && shown > Pixels::ZERO && shown < natural(&tabs, window, cx); let theme = cx.theme(); let colors = &theme.colors; let (thickness, duration) = (theme.tab_indicator(), motion::duration(motion::SLOW, cx)); let indicator = marker.map( |Marker { from, to, generation, }| { let bar = div().absolute().bg(colors.accent); let bar = match placement { TabPlacement::Top => bar.bottom_0().h(thickness), TabPlacement::Bottom => bar.top_0().h(thickness), TabPlacement::Left => bar.right_0().w(thickness), }; bar.with_animation( ("tab-indicator", generation), Animation::new(duration), move |bar, t| { let (start, length) = glide(from, to, t); match axis { Axis::Horizontal => bar.left(start).w(length), Axis::Vertical => bar.top(start).h(length), } }, ) }, ); let items = tabs .iter() .enumerate() .map(|(ix, tab)| { let on = Some(ix) == chosen; let fg = match (tab.disabled, on) { (true, _) => colors.fg_disabled, (false, true) => colors.fg, (false, false) => colors.fg_muted, }; let pick = pick.clone(); let context = self.on_context.clone().filter(|_| !tab.disabled); let value = tab.value.clone(); div() .id(("tab", ix)) .role(Role::Tab) .aria_selected(on) .aria_label(tab.label.clone()) .relative() .flex() .items_center() .gap_1p5() .h(theme.control_height(ControlSize::Md)) .px_3() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(if focused && on { colors.focus } else { gpui::transparent_black() }) .text_size(theme.text_size(TextSize::Sm)) .font_weight(if on { FontWeight::MEDIUM } else { FontWeight::NORMAL }) .text_color(fg) .when(!tab.disabled, |item| { item.cursor_pointer() .hover(|style| style.text_color(colors.fg)) .on_mouse_down(MouseButton::Left, |_, window, _| { window.prevent_default() }) .on_click(move |_, window, cx| { if !on { pick(ix, window, cx); } }) }) .when_some(context, |item, context| { item.on_mouse_down(MouseButton::Right, move |event, window, cx| { window.prevent_default(); cx.stop_propagation(); log::info!("tabs: context on {value}"); context(&value, event.position, window, cx); }) }) .when_some(tab.icon, |item, icon| { item.child(Icon::new(icon).size(IconSize::Sm).color(fg)) }) .when(compact, |item| { item.tooltip(Tooltip::text(tab.label.clone())) }) .when(!compact, |item| item.child(tab.label.clone())) .when_some(tab.note.clone().filter(|_| !compact), |item, note| { item.child(div().text_color(colors.fg_subtle).child(note)) }) .child(measure_item(state.clone(), ix, axis)) }) .collect::>(); let (keys, key_pick) = (tabs.clone(), pick.clone()); let strip = div() .id((self.id.clone(), "strip")) .role(Role::TabList) .track_focus(&focus) .relative() .flex() .when(axis == Axis::Vertical, |strip| strip.flex_col()) .gap_1() .border_color(colors.border) .map(|strip| match placement { TabPlacement::Top => strip.border_b_1(), TabPlacement::Bottom => strip.border_t_1(), TabPlacement::Left => strip.border_r_1().pr_1(), }) .on_key_down(move |event, window, cx| { if keys.is_empty() { return; } let back = if axis == Axis::Vertical { "up" } else { "left" }; let ahead = if axis == Axis::Vertical { "down" } else { "right" }; let key = event.keystroke.key.as_str(); let (from_back, from_ahead) = match chosen { Some(at) => (at, at), None => (0, keys.len() - 1), }; let to = match key { key if key == back => step(&keys, from_back, -1), key if key == ahead => step(&keys, from_ahead, 1), "home" => step(&keys, keys.len() - 1, 1), "end" => step(&keys, 0, -1), _ => return, }; cx.stop_propagation(); if Some(to) != chosen && !keys[to].disabled { key_pick(to, window, cx); } }) .child(measure_width(strip_width)) .child(measure_origin(state, axis)) .children(indicator) .children(items); let panel = self.panel.map(|panel| { div().flex_1().min_w_0().child(panel).with_animation( ("tab-panel", turn), Animation::new(motion::duration(motion::BASE, cx)) .with_easing(motion::ease_out_cubic), move |panel, t| if turn == 0 { panel } else { panel.opacity(t) }, ) }); let frame = div().id(self.id).flex().gap_4(); match placement { TabPlacement::Top => frame.flex_col().child(strip).children(panel), TabPlacement::Bottom => frame.flex_col().children(panel).child(strip), TabPlacement::Left => frame.child(strip).children(panel), } } } /// Every tab's icon, label and note in full: the width that cuts none. fn natural(tabs: &[Choice], window: &Window, cx: &App) -> Pixels { let theme = cx.theme(); let rem = window.rem_size(); let pad = (gpui::rems(0.75).to_pixels(rem) + theme.hairline()) * 2.0; let glyph = theme.icon_size(IconSize::Sm).to_pixels(rem) + gpui::rems(0.375).to_pixels(rem); let gap = gpui::rems(0.25).to_pixels(rem); tabs.iter().fold(Pixels::ZERO, |sum, tab| { let text = |text: &SharedString| text_width(text, TextSize::Sm, FontWeight::MEDIUM, window, cx); let note = tab.note.as_ref().map_or(Pixels::ZERO, |note| { text(note) + gpui::rems(0.375).to_pixels(rem) }); let icon = if tab.icon.is_some() { glyph } else { Pixels::ZERO }; sum + pad + icon + text(&tab.label) + note + gap }) }