From 749b3e4e2922e06da1ab7ee99f82da4409165f55 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 1 Nov 2026 14:01:49 +0700 Subject: [PATCH] add segmented control --- crates/core/Cargo.toml | 2 + crates/core/src/lib.rs | 3 + crates/core/src/segmented.rs | 363 +++++++++++++++++++++++++++++++++ crates/core/tests/segmented.rs | 148 ++++++++++++++ crates/theme/src/lib.rs | 6 + docs/PLAN.md | 2 +- examples/gallery/src/main.rs | 66 +++++- 7 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/segmented.rs create mode 100644 crates/core/tests/segmented.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 31eb87d..4c17b49 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -30,6 +30,7 @@ full = [ "input", "popover", "scroll_area", + "segmented", "tooltip", "menu", "select", @@ -50,6 +51,7 @@ badge = [] input = [] popover = [] scroll_area = [] +segmented = [] tooltip = [] menu = ["scroll_area"] select = ["menu"] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4f576d7..a2aa287 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -57,6 +57,9 @@ pub mod popover; #[cfg(feature = "scroll_area")] pub mod scroll_area; +#[cfg(feature = "segmented")] +pub mod segmented; + #[cfg(feature = "toast")] pub mod toast; diff --git a/crates/core/src/segmented.rs b/crates/core/src/segmented.rs new file mode 100644 index 0000000..dc818f0 --- /dev/null +++ b/crates/core/src/segmented.rs @@ -0,0 +1,363 @@ +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, rems, +}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::{ActiveTheme as _, Size}; +use gpui_native_utils::{adjacent_selectable, 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, role: IconRole) -> Self { + self.icon = Some(role); + 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, + options: Vec>, + 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: Vec::new(), + 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 icon_size = metrics.icon_size * 0.85; + + let track_height = match self.size { + Size::Small => metrics.control_height, + Size::Medium => metrics.control_height * 1.15, + Size::Large => metrics.control_height * 1.35, + }; + + let track_inset = match self.size { + Size::Small => rems(0.125), + Size::Medium => rems(0.25), + Size::Large => rems(0.375), + }; + + let horizontal_padding = match self.size { + Size::Small => metrics.padding_x * 0.5, + Size::Medium | Size::Large => metrics.padding_x * 0.75, + }; + + let text_size = match self.size { + Size::Small => metrics.text_size * 0.85, + Size::Medium | Size::Large => metrics.text_size, + }; + + let selected_index = self + .options + .iter() + .position(|segment| segment.value == self.selected); + + let selectable: Vec = (0..self.options.len()).collect(); + let on_change = self.on_change.clone(); + let key_on_change = on_change.clone(); + let key_options = self.options.clone(); + let key_selectable = selectable.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 focus = focus_handle(&self.id, window, cx); + + 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 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 + .into_iter() + .enumerate() + .map(move |(index, segment)| { + let is_selected = Some(index) == selected_index; + let on_change = on_change.clone(); + let value = segment.value; + 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, |segment, role| { + segment.child(icon(role).size(icon_size).color(content_color)) + }) + .child(segment.label) + .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 + { + on_change(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" => { + adjacent_selectable(&key_selectable, selected_index, true) + } + "left" => { + adjacent_selectable(&key_selectable, selected_index, false) + } + "home" => key_selectable.first().copied(), + "end" => key_selectable.last().copied(), + _ => return, + }; + if let Some(next) = target + && Some(next) != selected_index + && let Some(on_change) = &key_on_change + { + on_change(key_options[next].value.clone(), window, cx); + } + cx.stop_propagation(); + } + }) + .children(segments) + .refine_style(&self.style), + ) + }) + } +} diff --git a/crates/core/tests/segmented.rs b/crates/core/tests/segmented.rs new file mode 100644 index 0000000..d78721b --- /dev/null +++ b/crates/core/tests/segmented.rs @@ -0,0 +1,148 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, point, px, +}; +use gpui_native_core::segmented::Segmented; +use gpui_native_theme::Theme; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Filter { + All, + Popular, + Recent, +} + +const FILTERS: [(Filter, &str); 3] = [ + (Filter::All, "All"), + (Filter::Popular, "Popular"), + (Filter::Recent, "Recent"), +]; + +type Changes = Rc>>; + +struct Harness { + filter: Filter, + changes: Changes, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + div().size_full().p_4().child( + Segmented::new("filter", self.filter) + .options(FILTERS) + .on_change(move |next, _window, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.filter = next; + cx.notify(); + }); + }), + ) + } +} + +fn filter_window() -> (TestAppContext, VisualTestContext, Changes) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + filter: Filter::All, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, changes) +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default()); +} + +/// The strip hugs its labels in the `p_4` band: the three segments span +/// roughly `18..142` horizontally and `17.6..33.9` vertically (macOS's 13 pt +/// root font), so clicks land near `32` (All), `70` (Popular), and `118` +/// (Recent). +#[test] +fn clicking_a_segment_reports_it() { + let (_cx, mut visual, changes) = filter_window(); + + click(&mut visual, point(px(70.), px(27.))); + assert_eq!(*changes.borrow(), vec![Filter::Popular]); + + click(&mut visual, point(px(118.), px(27.))); + assert_eq!(*changes.borrow(), vec![Filter::Popular, Filter::Recent]); +} + +#[test] +fn re_clicking_the_selected_segment_reports_nothing() { + let (_cx, mut visual, changes) = filter_window(); + + click(&mut visual, point(px(70.), px(27.))); + assert_eq!(*changes.borrow(), vec![Filter::Popular]); + + click(&mut visual, point(px(70.), px(27.))); + assert_eq!(*changes.borrow(), vec![Filter::Popular]); +} + +#[test] +fn arrows_move_the_selection_when_the_strip_has_focus() { + let (_cx, mut visual, changes) = filter_window(); + + // Clicking a segment hands the strip the keyboard, like the list. + click(&mut visual, point(px(32.), px(27.))); + visual.simulate_keystrokes("right"); + assert_eq!(*changes.borrow(), vec![Filter::Popular]); + + visual.simulate_keystrokes("right"); + assert_eq!(*changes.borrow(), vec![Filter::Popular, Filter::Recent]); + + // The strip never wraps: right on the last segment reports nothing. + visual.simulate_keystrokes("right"); + assert_eq!(*changes.borrow(), vec![Filter::Popular, Filter::Recent]); + + // Home and end jump to the ends. + visual.simulate_keystrokes("home"); + assert_eq!( + *changes.borrow(), + vec![Filter::Popular, Filter::Recent, Filter::All] + ); + + visual.simulate_keystrokes("end"); + assert_eq!( + *changes.borrow(), + vec![Filter::Popular, Filter::Recent, Filter::All, Filter::Recent] + ); +} + +/// The selected plate exists after a choice and settles on the chosen +/// segment's slot; without it the active background disappears entirely. +#[test] +fn the_plate_settles_on_the_chosen_segment() { + let (_cx, mut visual, changes) = filter_window(); + + click(&mut visual, point(px(70.), px(27.))); + assert_eq!(*changes.borrow(), vec![Filter::Popular]); + + // Let the spring settle in real time (it steps on Instant::now()), then + // check the plate landed in Popular's slot (~58..124). + std::thread::sleep(std::time::Duration::from_millis(600)); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + let plate = visual + .debug_bounds("segmented-plate") + .expect("plate missing"); + assert!( + plate.left() >= px(44.) && plate.left() <= px(94.), + "plate settled at x {:?}", + plate.origin + ); +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 2d138e3..aa4a7e2 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -160,6 +160,8 @@ pub struct Elevations { pub popover: Elevation, /// Modal dialogs sit higher with a wider one. pub dialog: Elevation, + /// Raised plates inside controls — a selected segmented segment. + pub control: Elevation, } impl Elevations { @@ -168,6 +170,7 @@ impl Elevations { return Self { popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)), dialog: Elevation::single(Shadow::black(0., 8., 40., 0.35)), + control: Elevation::single(Shadow::black(0., 1., 2., 0.25)), }; } @@ -181,6 +184,7 @@ impl Elevations { Shadow::black(0., 8., 40., 0.14), Shadow::black(0., 1., 2., 0.06), ), + control: Elevation::single(Shadow::black(0., 1., 2., 0.1)), }, SourceKind::Windows => Self { popover: Elevation::layered( @@ -191,11 +195,13 @@ impl Elevations { Shadow::black(0., 16., 32., 0.16), Shadow::black(0., 4., 8., 0.1), ), + control: Elevation::single(Shadow::black(0., 1., 2., 0.12)), }, SourceKind::Omarchy | SourceKind::Kde | SourceKind::Gnome | SourceKind::Fallback => { Self { popover: Elevation::single(Shadow::black(0., 3., 6., 0.2)), dialog: Elevation::single(Shadow::black(0., 9., 18., 0.24)), + control: Elevation::single(Shadow::black(0., 1., 2., 0.16)), } } } diff --git a/docs/PLAN.md b/docs/PLAN.md index 34af462..f45d29a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -262,7 +262,7 @@ Wave M1 — unblocks the port; each through the review gate: | --- | --- | --- | --- | --- | | M1 | core | Badge ✅ Landed (2026-11-01) | unsynced-count pills on sidebar repo rows | bezel ships quiet + active variants; gpuikit secondary/outline/destructive. Shipped five semantic variants — Neutral (muted fill), Accent, Success, Warning, Danger (the ramps) — painted from existing roles, no badge palette (D22); pill = `Radius.full`, size-table text at 0.8×, caller-refinable via `Styled` | | M2 | core | Select ✅ Landed (2026-11-01) | default-branch chooser, event-fetch-strategy setting, explore filter | bezel: field-shaped trigger over a caller-assembled menu; gpuikit: placeholder + clear. Shipped as a controlled value (`selected(Option)` + `on_change(Option)`, tuples in) with the popup owned per instance via element-namespace-keyed state; composes `Menu` — field-shaped trigger (`input`/`border`/`ring` tokens, `Role::ComboBox`), options as checked items. Menu gained `initial_selection` so a popup opens keyboard-highlighted on the current value, like native popups, and its panel dropped the `max_h(relative(0.75))` fraction — resolved against the wrong box inside nested flex/scroll shells, it clipped the last items (found in the gallery); row height is governed by the ScrollArea's 320 px cap and `anchored` handles window fit. Feature `select = ["menu"]`. **Follow-ups (2026-11-01):** Menu's check indicator moved to the row's right end (Adwaita dropdown look; the optional icon keeps the leading slot), and Select options gained optional leading icons — `SelectOption::new(value, label).icon(role)`, with `(value, label)` tuples still accepted through `From` | -| M3 | core | Segmented control | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select first, multiple waits | +| M3 | core | Segmented control ✅ Landed (2026-11-01) | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select only, multiple waits. Shipped as a controlled single-select (`Segmented::new(id, selected)` + `options((value, label))` + `on_change(T)` — exactly one segment always active); muted pill track, the selected segment on a raised `surface` plate with border; left/right/home/end move and activate when the strip has focus (no wrap), `RadioGroup`/`RadioButton` roles with `aria_toggled`; renders hugging via a wrapper so it works in block and flex parents alike. Plain `segmented` feature, no deps. **Follow-ups (2026-11-01):** the selected plate dropped its border for a small raised shadow — a third `Elevations.control` class, profiled per platform like popover/dialog (D24); `Size::Small` became the default and medium/large resolve through the size table — taller tracks (`control_height × 1.15 / 1.35`), a scaled track inset (`0.125 / 0.25 / 0.375 rem`) and segment padding (`0.5× / 0.75× / 0.75×` of `padding_x`), with the large label staying at the medium size; options take optional leading icons (`SegmentedOption::new(value, label).icon(role)`, tuples still accepted through `From`); and the selected plate is now a measured, absolutely-positioned layer that springs between segments (per-segment measure canvases feed keyed geometry; the plate springs on `left` and `width` separately, Switch-style — mounts at its target so the first frame never animates, retargets mid-flight, and snaps under reduce-motion). **Follow-up 2 (2026-11-01):** the track inset also scales per size (`0.125 / 0.25 / 0.375 rem`), and a settled-plate regression test guards the plate's existence and slot (`debug_bounds("segmented-plate")`) | | M4 | core | Context menu | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Reuses the Menu panel | | M5 | core | Empty state | inbox, issues, search misses | bezel: icon + headline + hint + optional action. Trivial paint over tokens | | M6 | input | Password input | identity unlock + `nsec` import passphrase | lifts §6.2's "password masking on first need" — the need arrived | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 60e389a..8f56d66 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -20,6 +20,7 @@ use gpui_native_core::popover::Popover; use gpui_native_core::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::scroll_area::ScrollArea; +use gpui_native_core::segmented::{Segmented, SegmentedOption}; use gpui_native_core::select::{Select, SelectOption}; use gpui_native_core::slider::Slider; use gpui_native_core::switch::Switch; @@ -31,7 +32,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; use gpui_native_data::tabs::{Tab, Tabs}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; -use gpui_native_theme::{ActiveTheme as _, Theme}; +use gpui_native_theme::{ActiveTheme as _, Size, Theme}; use gpui_platform::application; struct Gallery { @@ -45,6 +46,7 @@ struct Gallery { popover_open: bool, menu_open: bool, strategy: Option, + explore_filter: ExploreFilter, dialog_open: bool, alert_open: bool, update_alert_dismissed: bool, @@ -65,6 +67,15 @@ enum Strategy { Uncensored, } +/// The segmented section's demo value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ExploreFilter { + #[default] + All, + Popular, + Recent, +} + /// A demo model for the tree section: the app owns the real tree and its /// expansion set, and flattens the visible nodes for the component. #[derive(Clone)] @@ -462,6 +473,58 @@ impl Render for Gallery { ) }), ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label( + "segmented · native refs — NSSegmentedControl, WinUI Segmented, Adwaita AdwToggleGroup", + colors, + )) + .child({ + let gallery = gallery.clone(); + div() + .flex() + .flex_col() + .gap_2() + .child( + Segmented::new("gallery-explore", self.explore_filter) + .options(vec![ + (ExploreFilter::All, "All"), + (ExploreFilter::Popular, "Popular"), + (ExploreFilter::Recent, "Recent"), + ]) + .on_change(move |next, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.explore_filter = next; + cx.notify(); + }); + }), + ) + .child( + Segmented::new("gallery-explore-medium", self.explore_filter) + .options(vec![ + SegmentedOption::new(ExploreFilter::All, "All") + .icon(IconRole::Check), + SegmentedOption::new(ExploreFilter::Popular, "Popular") + .icon(IconRole::Search), + SegmentedOption::new(ExploreFilter::Recent, "Recent") + .icon(IconRole::Info), + ]) + .with_size(Size::Medium), + ) + .child( + Segmented::new("gallery-explore-large", self.explore_filter) + .options(vec![ + (ExploreFilter::All, "All"), + (ExploreFilter::Popular, "Popular"), + (ExploreFilter::Recent, "Recent"), + ]) + .large(), + ) + }), + ) .child( v_flex() .gap_2() @@ -1330,6 +1393,7 @@ fn main() { popover_open: false, menu_open: false, strategy: Some(Strategy::Curated), + explore_filter: ExploreFilter::All, dialog_open: false, alert_open: false, update_alert_dismissed: false,