add segmented control

This commit is contained in:
2026-11-01 14:01:49 +07:00
parent bbb56fdda7
commit 749b3e4e29
7 changed files with 588 additions and 2 deletions
+2
View File
@@ -30,6 +30,7 @@ full = [
"input", "input",
"popover", "popover",
"scroll_area", "scroll_area",
"segmented",
"tooltip", "tooltip",
"menu", "menu",
"select", "select",
@@ -50,6 +51,7 @@ badge = []
input = [] input = []
popover = [] popover = []
scroll_area = [] scroll_area = []
segmented = []
tooltip = [] tooltip = []
menu = ["scroll_area"] menu = ["scroll_area"]
select = ["menu"] select = ["menu"]
+3
View File
@@ -57,6 +57,9 @@ pub mod popover;
#[cfg(feature = "scroll_area")] #[cfg(feature = "scroll_area")]
pub mod scroll_area; pub mod scroll_area;
#[cfg(feature = "segmented")]
pub mod segmented;
#[cfg(feature = "toast")] #[cfg(feature = "toast")]
pub mod toast; pub mod toast;
+363
View File
@@ -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<T> = Rc<dyn Fn(T, &mut Window, &mut App)>;
/// Segment option for a segmented control.
#[derive(Clone)]
pub struct SegmentedOption<T> {
value: T,
label: SharedString,
icon: Option<IconRole>,
}
impl<T> SegmentedOption<T> {
/// Creates a choice showing `label`; picking it reports `value`.
pub fn new<L>(value: T, label: L) -> Self
where
L: Into<SharedString>,
{
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<T, L> From<(T, L)> for SegmentedOption<T>
where
L: Into<SharedString>,
{
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<T>
where
T: Clone + PartialEq + 'static,
{
id: ElementId,
options: Vec<SegmentedOption<T>>,
selected: T,
a11y_label: Option<SharedString>,
size: Size,
style: StyleRefinement,
on_change: Option<ChangeHandler<T>>,
}
impl<T> Segmented<T>
where
T: Clone + PartialEq + 'static,
{
pub fn new<I>(id: I, selected: T) -> Self
where
I: Into<ElementId>,
{
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<I>(mut self, options: I) -> Self
where
I: IntoIterator,
I::Item: Into<SegmentedOption<T>>,
{
self.options = options.into_iter().map(Into::into).collect();
self
}
/// Reports the segment chosen, by pointer or by arrow.
pub fn on_change<F>(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<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
}
impl<T> Sizable for Segmented<T>
where
T: Clone + PartialEq + 'static,
{
fn apply_size(&mut self, size: Size) {
self.size = size;
}
}
impl<T> gpui::Styled for Segmented<T>
where
T: Clone + PartialEq + 'static,
{
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl<T> RenderOnce for Segmented<T>
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<usize> = (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<Vec<Option<Bounds<gpui::Pixels>>>> =
window.use_keyed_state("segmented-geometry", cx, |_, _| Vec::new());
let track_origin: Entity<Option<Point<gpui::Pixels>>> =
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),
)
})
}
}
+148
View File
@@ -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<RefCell<Vec<Filter>>>;
struct Harness {
filter: Filter,
changes: Changes,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<gpui::Pixels>) {
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
);
}
+6
View File
@@ -160,6 +160,8 @@ pub struct Elevations {
pub popover: Elevation, pub popover: Elevation,
/// Modal dialogs sit higher with a wider one. /// Modal dialogs sit higher with a wider one.
pub dialog: Elevation, pub dialog: Elevation,
/// Raised plates inside controls — a selected segmented segment.
pub control: Elevation,
} }
impl Elevations { impl Elevations {
@@ -168,6 +170,7 @@ impl Elevations {
return Self { return Self {
popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)), popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)),
dialog: Elevation::single(Shadow::black(0., 8., 40., 0.35)), 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., 8., 40., 0.14),
Shadow::black(0., 1., 2., 0.06), Shadow::black(0., 1., 2., 0.06),
), ),
control: Elevation::single(Shadow::black(0., 1., 2., 0.1)),
}, },
SourceKind::Windows => Self { SourceKind::Windows => Self {
popover: Elevation::layered( popover: Elevation::layered(
@@ -191,11 +195,13 @@ impl Elevations {
Shadow::black(0., 16., 32., 0.16), Shadow::black(0., 16., 32., 0.16),
Shadow::black(0., 4., 8., 0.1), 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 => { SourceKind::Omarchy | SourceKind::Kde | SourceKind::Gnome | SourceKind::Fallback => {
Self { Self {
popover: Elevation::single(Shadow::black(0., 3., 6., 0.2)), popover: Elevation::single(Shadow::black(0., 3., 6., 0.2)),
dialog: Elevation::single(Shadow::black(0., 9., 18., 0.24)), dialog: Elevation::single(Shadow::black(0., 9., 18., 0.24)),
control: Elevation::single(Shadow::black(0., 1., 2., 0.16)),
} }
} }
} }
+1 -1
View File
@@ -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` | | 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<T>)` + `on_change(Option<T>)`, 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` | | 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<T>)` + `on_change(Option<T>)`, 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 | | 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 | | 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 | | M6 | input | Password input | identity unlock + `nsec` import passphrase | lifts §6.2's "password masking on first need" — the need arrived |
+65 -1
View File
@@ -20,6 +20,7 @@ use gpui_native_core::popover::Popover;
use gpui_native_core::progress::Progress; use gpui_native_core::progress::Progress;
use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::radio::{Radio, RadioGroup};
use gpui_native_core::scroll_area::ScrollArea; 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::select::{Select, SelectOption};
use gpui_native_core::slider::Slider; use gpui_native_core::slider::Slider;
use gpui_native_core::switch::Switch; 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::tabs::{Tab, Tabs};
use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_data::tree::{Tree, TreeItem};
use gpui_native_icons::{IconAssets, IconRole, icon}; 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; use gpui_platform::application;
struct Gallery { struct Gallery {
@@ -45,6 +46,7 @@ struct Gallery {
popover_open: bool, popover_open: bool,
menu_open: bool, menu_open: bool,
strategy: Option<Strategy>, strategy: Option<Strategy>,
explore_filter: ExploreFilter,
dialog_open: bool, dialog_open: bool,
alert_open: bool, alert_open: bool,
update_alert_dismissed: bool, update_alert_dismissed: bool,
@@ -65,6 +67,15 @@ enum Strategy {
Uncensored, 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 /// 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. /// expansion set, and flattens the visible nodes for the component.
#[derive(Clone)] #[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( .child(
v_flex() v_flex()
.gap_2() .gap_2()
@@ -1330,6 +1393,7 @@ fn main() {
popover_open: false, popover_open: false,
menu_open: false, menu_open: false,
strategy: Some(Strategy::Curated), strategy: Some(Strategy::Curated),
explore_filter: ExploreFilter::All,
dialog_open: false, dialog_open: false,
alert_open: false, alert_open: false,
update_alert_dismissed: false, update_alert_dismissed: false,