use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation, SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, canvas, div, }; use gpui_native_icons::Icon; use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_utils::{focus_handle, focus_ring}; use crate::common::{Sizable, StyledExt, h_flex}; /// Reports the segment the user chose. type ChangeHandler = Rc; /// Segment option for a segmented control. #[derive(Clone)] pub struct SegmentedOption { value: T, label: SharedString, icon: Option, } impl SegmentedOption { /// Creates a choice showing `label`; picking it reports `value`. pub fn new(value: T, label: L) -> Self where L: Into, { Self { value, label: label.into(), icon: None, } } /// Sets a leading icon for the segment. pub fn icon(mut self, icon: I) -> Self where I: Into, { self.icon = Some(icon.into()); self } } impl From<(T, L)> for SegmentedOption where L: Into, { fn from((value, label): (T, L)) -> Self { Self { value, label: label.into(), icon: None, } } } /// A pill strip of mutually exclusive choices. #[derive(IntoElement)] pub struct Segmented where T: Clone + PartialEq + 'static, { id: ElementId, /// Shared with the per-segment and key handlers, so captures are bumps. options: Rc<[SegmentedOption]>, selected: T, a11y_label: Option, size: Size, style: StyleRefinement, on_change: Option>, } impl Segmented where T: Clone + PartialEq + 'static, { pub fn new(id: I, selected: T) -> Self where I: Into, { Self { id: id.into(), options: Rc::from([]), selected, a11y_label: None, size: Size::Small, style: StyleRefinement::default(), on_change: None, } } /// Sets the choices. pub fn options(mut self, options: I) -> Self where I: IntoIterator, I::Item: Into>, { self.options = options.into_iter().map(Into::into).collect(); self } /// Reports the segment chosen, by pointer or by arrow. pub fn on_change(mut self, handler: F) -> Self where F: Fn(T, &mut Window, &mut App) + 'static, { self.on_change = Some(Rc::new(handler)); self } /// Overrides the strip's accessibility label. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl Sizable for Segmented where T: Clone + PartialEq + 'static, { fn apply_size(&mut self, size: Size) { self.size = size; } } impl gpui::Styled for Segmented where T: Clone + PartialEq + 'static, { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for Segmented where T: Clone + PartialEq + 'static, { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let metrics = theme.sizes.resolve(self.size); let muted = theme.colors.muted; let surface = theme.colors.surface; let foreground = theme.colors.foreground; let muted_foreground = theme.colors.muted_foreground; let ring = theme.colors.ring; let radius = theme.radius.full; let plate_shadow = theme.elevations.control.shadows(); let track_height = metrics.strip_height; let track_inset = metrics.plate_inset; let horizontal_padding = metrics.dense_padding_x; let text_size = metrics.caption_size; let icon_size = metrics.icon_size; let selected_index = self .options .iter() .position(|segment| segment.value == self.selected); let option_count = self.options.len(); let on_change = self.on_change.clone(); let key_on_change = on_change.clone(); let key_options = self.options.clone(); let options = self.options.clone(); let label = self .a11y_label .clone() .unwrap_or_else(|| SharedString::from(self.id.to_string())); window.with_element_namespace(self.id.clone(), |window| { let geometry: Entity>>> = window.use_keyed_state("segmented-geometry", cx, |_, _| Vec::new()); let track_origin: Entity>> = window.use_keyed_state("segmented-track-origin", cx, |_, _| None); let focus = focus_handle(&self.id, window, cx); let anim_config = SpringConfig::new(500., 35., 1.); let track_point = *track_origin.read(cx); let plate_target = geometry .read(cx) .get(selected_index.unwrap_or(usize::MAX)) .copied() .flatten() .zip(track_point) .map(|(bounds, track)| (bounds.origin.x - track.x, bounds.size.width)); let segments = self .options .iter() .enumerate() .map(move |(index, segment)| { let is_selected = Some(index) == selected_index; let on_change = on_change.clone(); let options = options.clone(); let content_color = if is_selected { foreground } else { muted_foreground }; h_flex() .id(("segment", index)) .gap_1p5() .justify_center() .cursor_pointer() .h_full() .px(horizontal_padding) .rounded(radius) .text_size(text_size) .text_color(content_color) .when(!is_selected, |segment| { segment.hover(|style| style.bg(surface)) }) .role(Role::RadioButton) .aria_label(segment.label.clone()) .aria_toggled(Toggled::from(is_selected)) .when_some(segment.icon.clone(), |segment, icon| { segment.child(icon.size(icon_size).color(content_color)) }) .child(segment.label.clone()) .child( canvas( { let geometry = geometry.clone(); move |bounds, _, cx| { geometry.update(cx, |geometry, _| { if geometry.len() <= index { geometry.resize(index + 1, None); } geometry[index] = Some(bounds); }); } }, |_, _, _, _| {}, ) .absolute() .inset_0(), ) .on_click({ move |_, window, cx| { if Some(index) != selected_index && let Some(on_change) = &on_change && let Some(option) = options.get(index) { on_change(option.value.clone(), window, cx); } } }) }); div().child( div() .id(self.id.clone()) .track_focus(&focus) .focusable() .tab_stop(true) .focus_visible(|style| style.shadow(focus_ring(ring))) .role(Role::RadioGroup) .aria_label(label) .flex() .items_center() .gap_1() .relative() .flex_none() .h(track_height) .p(track_inset) .rounded(radius) .bg(muted) .text_size(metrics.text_size) .child( canvas( { let track_origin = track_origin.clone(); move |bounds, _, cx| { track_origin .update(cx, |origin, _| *origin = Some(bounds.origin)); } }, |_, _, _, _| {}, ) .absolute() .inset_0(), ) .when_some(plate_target, |track, (x, width)| { track.child( div() .id("segmented-plate") .debug_selector(|| "segmented-plate".to_string()) .absolute() .top(track_inset) .bottom(track_inset) .rounded(radius) .bg(surface) .shadow(plate_shadow.clone()) .with_spring( "segmented-plate-x", SpringAnimation::new(anim_config).to(x), |plate, x| plate.left(x), ) .with_spring( "segmented-plate-w", SpringAnimation::new(anim_config).to(width), |spring_plate, width| { spring_plate.map_element(move |plate| plate.w(width)) }, ), ) }) .on_key_down({ move |event: &KeyDownEvent, window, cx| { let target = match event.keystroke.key.as_str() { "right" => match selected_index { Some(index) => { index.checked_add(1).filter(|next| *next < option_count) } None => (option_count > 0).then_some(0), }, "left" => selected_index.and_then(|index| index.checked_sub(1)), "home" => (option_count > 0).then_some(0), "end" => option_count.checked_sub(1), _ => return, }; if let Some(next) = target && Some(next) != selected_index && let Some(on_change) = &key_on_change && let Some(option) = key_options.get(next) { on_change(option.value.clone(), window, cx); } cx.stop_propagation(); } }) .children(segments) .refine_style(&self.style), ) }) } }