add segmented control
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user