add slider
This commit is contained in:
@@ -19,3 +19,6 @@ pub mod toggle;
|
|||||||
|
|
||||||
#[cfg(feature = "link")]
|
#[cfg(feature = "link")]
|
||||||
pub mod link;
|
pub mod link;
|
||||||
|
|
||||||
|
#[cfg(feature = "slider")]
|
||||||
|
pub mod slider;
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
App, Bounds, BoxShadow, DispatchPhase, ElementId, InteractiveElement as _, IntoElement,
|
||||||
|
KeyDownEvent, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
|
||||||
|
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
|
||||||
|
div, px, relative, rems,
|
||||||
|
};
|
||||||
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
|
use crate::common::h_flex;
|
||||||
|
|
||||||
|
/// The change reporter, fired with the next value by click, drag, and arrows.
|
||||||
|
type ChangeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// The pointer→value setter shared by the click and drag listeners.
|
||||||
|
type ValueSetter = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
|
||||||
|
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
|
||||||
|
let clamped = value.clamp(min, max);
|
||||||
|
if step > 0.0 {
|
||||||
|
(min + ((clamped - min) / step).round() * step).clamp(min, max)
|
||||||
|
} else {
|
||||||
|
clamped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A controlled slider over `[min, max]`.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct Slider {
|
||||||
|
id: ElementId,
|
||||||
|
min: f32,
|
||||||
|
max: f32,
|
||||||
|
step: f32,
|
||||||
|
value: f32,
|
||||||
|
disabled: bool,
|
||||||
|
a11y_label: Option<SharedString>,
|
||||||
|
on_change: Option<ChangeHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slider {
|
||||||
|
/// Creates a slider over `0..=1` with no stepping, at `value`.
|
||||||
|
pub fn new<I>(id: I, value: f32) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
step: 0.0,
|
||||||
|
value,
|
||||||
|
disabled: false,
|
||||||
|
a11y_label: None,
|
||||||
|
on_change: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the range's lower bound.
|
||||||
|
pub fn min(mut self, min: f32) -> Self {
|
||||||
|
self.min = min;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the range's upper bound.
|
||||||
|
pub fn max(mut self, max: f32) -> Self {
|
||||||
|
self.max = max;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the step; values snap to `min + n * step`. `0` is continuous.
|
||||||
|
pub fn step(mut self, step: f32) -> Self {
|
||||||
|
self.step = step;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the current value; the slider never owns it.
|
||||||
|
pub fn value(mut self, value: f32) -> Self {
|
||||||
|
self.value = value;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the slider inert, unfocusable, and dimmed; disabled always wins.
|
||||||
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
|
self.disabled = disabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the accessibility label, required for an unlabeled slider.
|
||||||
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.a11y_label = Some(label.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The change handler, fired with the next value by click, drag, and arrows.
|
||||||
|
pub fn on_change<F>(mut self, handler: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(f32, &mut Window, &mut App) + 'static,
|
||||||
|
{
|
||||||
|
self.on_change = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Slider {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors;
|
||||||
|
let ramps = theme.ramps;
|
||||||
|
let radius = theme.radius;
|
||||||
|
|
||||||
|
let value = self.value.clamp(self.min, self.max);
|
||||||
|
let fraction = if self.max > self.min {
|
||||||
|
(value - self.min) / (self.max - self.min)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// The track bounds, recorded by the overlay canvas each frame,
|
||||||
|
// turn pointer positions into values inside the listeners.
|
||||||
|
let bounds_cell = Rc::new(Cell::new(None::<Bounds<Pixels>>));
|
||||||
|
|
||||||
|
// Dragging crosses frames, so it lives in keyed element state,
|
||||||
|
// a per-render flag would reset when the owner's update repaints.
|
||||||
|
let dragging = window.use_keyed_state("slider-dragging", cx, |_, _| false);
|
||||||
|
|
||||||
|
// A held handle gives the arrow-key listener something to check focus against,
|
||||||
|
// and the click-to-focus behavior a target.
|
||||||
|
let focus = window.use_keyed_state("slider-focus", cx, |_, cx| cx.focus_handle());
|
||||||
|
let focus_handle = focus.read(cx).clone();
|
||||||
|
|
||||||
|
let set_value: ValueSetter = {
|
||||||
|
let bounds_cell = bounds_cell.clone();
|
||||||
|
let on_change = self.on_change.clone();
|
||||||
|
let (min, max, step) = (self.min, self.max, self.step);
|
||||||
|
|
||||||
|
Rc::new(move |x: Pixels, window: &mut Window, cx: &mut App| {
|
||||||
|
if let (Some(bounds), Some(on_change)) = (bounds_cell.get(), on_change.as_ref())
|
||||||
|
&& bounds.size.width > Pixels::ZERO
|
||||||
|
{
|
||||||
|
let fraction = ((x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
|
||||||
|
on_change(
|
||||||
|
constrain(min, max, step, min + fraction * (max - min)),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let down_dragging = dragging.clone();
|
||||||
|
let down_set_value = set_value.clone();
|
||||||
|
let disabled = self.disabled;
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(self.id)
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.h_5()
|
||||||
|
.role(Role::Slider)
|
||||||
|
.when_some(self.a11y_label.clone(), |slider, label| {
|
||||||
|
slider.aria_label(label)
|
||||||
|
})
|
||||||
|
.aria_numeric_value(f64::from(value))
|
||||||
|
.aria_min_numeric_value(f64::from(self.min))
|
||||||
|
.aria_max_numeric_value(f64::from(self.max))
|
||||||
|
.when(self.disabled, |slider| slider.opacity(0.5))
|
||||||
|
.when(!self.disabled, |slider| {
|
||||||
|
slider
|
||||||
|
.track_focus(&focus_handle)
|
||||||
|
.tab_stop(true)
|
||||||
|
.focus_visible(|style| {
|
||||||
|
style.shadow(vec![
|
||||||
|
BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||||
|
down_dragging.update(cx, |dragging, _| *dragging = true);
|
||||||
|
down_set_value(event.position.x, window, cx);
|
||||||
|
})
|
||||||
|
.on_key_down(move |event: &KeyDownEvent, window, cx| {
|
||||||
|
if !focus_handle.contains_focused(window, cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let single_step = if self.step > 0.0 {
|
||||||
|
self.step
|
||||||
|
} else {
|
||||||
|
(self.max - self.min) / 20.0
|
||||||
|
};
|
||||||
|
let next = match event.keystroke.key.as_str() {
|
||||||
|
"left" | "down" => Some(value - single_step),
|
||||||
|
"right" | "up" => Some(value + single_step),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(next) = next
|
||||||
|
&& let Some(on_change) = self.on_change.as_ref()
|
||||||
|
{
|
||||||
|
on_change(constrain(self.min, self.max, self.step, next), window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
canvas(
|
||||||
|
{
|
||||||
|
let bounds_cell = bounds_cell.clone();
|
||||||
|
move |bounds, _, _| bounds_cell.set(Some(bounds))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
let set_value = set_value.clone();
|
||||||
|
move |_, _, window, _cx| {
|
||||||
|
if disabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.on_mouse_event({
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
let set_value = set_value.clone();
|
||||||
|
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||||
|
if phase == DispatchPhase::Bubble
|
||||||
|
&& *dragging.read(cx)
|
||||||
|
&& event.pressed_button == Some(MouseButton::Left)
|
||||||
|
{
|
||||||
|
set_value(event.position.x, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.on_mouse_event({
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
move |event: &MouseUpEvent, phase, _window, cx| {
|
||||||
|
if phase == DispatchPhase::Bubble
|
||||||
|
&& event.button == MouseButton::Left
|
||||||
|
{
|
||||||
|
dragging.update(cx, |dragging, _| *dragging = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.left_0()
|
||||||
|
.top_2()
|
||||||
|
.h_1()
|
||||||
|
.w_full()
|
||||||
|
.rounded(radius.full)
|
||||||
|
.bg(colors.border),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.left_0()
|
||||||
|
.top_2()
|
||||||
|
.h_1()
|
||||||
|
.w(relative(fraction))
|
||||||
|
.rounded(radius.full)
|
||||||
|
.bg(ramps.primary.base),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.absolute()
|
||||||
|
.left_0()
|
||||||
|
.top_0()
|
||||||
|
.h_full()
|
||||||
|
.w(relative(fraction))
|
||||||
|
.justify_end()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size_5()
|
||||||
|
.flex_none()
|
||||||
|
.rounded(radius.full)
|
||||||
|
.bg(colors.primary_foreground)
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.mr(rems(-0.625)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyWindowHandle, AppContext as _, Context, InputEvent as _, IntoElement, KeyDownEvent,
|
||||||
|
Keystroke, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext,
|
||||||
|
VisualTestContext, Window, div, point, px,
|
||||||
|
};
|
||||||
|
use gpui_native_core::slider::Slider;
|
||||||
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
changes: Rc<RefCell<Vec<f32>>>,
|
||||||
|
value: f32,
|
||||||
|
disabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
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().flex().flex_col().child(
|
||||||
|
div().w(px(200.)).child(
|
||||||
|
Slider::new("test-slider", self.value)
|
||||||
|
.min(0.0)
|
||||||
|
.max(100.0)
|
||||||
|
.step(10.0)
|
||||||
|
.disabled(self.disabled)
|
||||||
|
.on_change(move |next, _, cx| {
|
||||||
|
changes.borrow_mut().push(next);
|
||||||
|
view.update(cx, |harness, cx| {
|
||||||
|
harness.value = next;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A maximized window holding a 200px slider over 0..=100 stepped by 10 in
|
||||||
|
/// the top-left corner: `x` maps to `round(x / 200 * 10) * 10`.
|
||||||
|
fn slider_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<Vec<f32>>>) {
|
||||||
|
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 {
|
||||||
|
changes: harness_changes,
|
||||||
|
value: 0.0,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
let window = window.into();
|
||||||
|
cx.update_window(window, |_, window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
(VisualTestContext::from_window(window, &cx), window, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) {
|
||||||
|
cx.update_window(window, |_, window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_the_track_sets_the_snapped_value() {
|
||||||
|
let (mut cx, _window, changes) = slider_window(false);
|
||||||
|
cx.simulate_click(point(px(100.), px(10.)), Modifiers::default());
|
||||||
|
assert_eq!(*changes.borrow(), vec![50.0]);
|
||||||
|
cx.simulate_click(point(px(40.), px(10.)), Modifiers::default());
|
||||||
|
assert_eq!(*changes.borrow(), vec![50.0, 20.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragging_reports_values_until_release() {
|
||||||
|
let (mut cx, _window, changes) = slider_window(false);
|
||||||
|
cx.simulate_mouse_down(
|
||||||
|
point(px(40.), px(10.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
cx.simulate_mouse_move(
|
||||||
|
point(px(120.), px(10.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
cx.simulate_mouse_up(
|
||||||
|
point(px(120.), px(10.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(*changes.borrow(), vec![20.0, 60.0]);
|
||||||
|
|
||||||
|
// Moving after release must not change the value.
|
||||||
|
cx.simulate_mouse_move(
|
||||||
|
point(px(180.), px(10.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(*changes.borrow(), vec![20.0, 60.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drag_clamps_to_the_range_ends() {
|
||||||
|
let (mut cx, _window, changes) = slider_window(false);
|
||||||
|
cx.simulate_mouse_down(
|
||||||
|
point(px(100.), px(10.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
// The drag captures the pointer: it keeps reporting past the track's
|
||||||
|
// right edge, clamped to the top of the range.
|
||||||
|
cx.simulate_mouse_move(
|
||||||
|
point(px(400.), px(10.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(*changes.borrow(), vec![50.0, 100.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_slider_does_not_report_changes() {
|
||||||
|
let (mut cx, _window, changes) = slider_window(true);
|
||||||
|
cx.simulate_click(point(px(100.), px(10.)), Modifiers::default());
|
||||||
|
assert!(changes.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_keys_step_the_value_while_focused() {
|
||||||
|
let (mut cx, window, changes) = slider_window(false);
|
||||||
|
// The click focuses the slider and sets the snapped value at that point.
|
||||||
|
cx.simulate_click(point(px(2.), px(10.)), Modifiers::default());
|
||||||
|
assert_eq!(*changes.borrow(), vec![0.0]);
|
||||||
|
|
||||||
|
draw(&mut cx, window);
|
||||||
|
|
||||||
|
for key in ["right", "right", "left"] {
|
||||||
|
cx.update_window(window, |_, window, cx| {
|
||||||
|
window.dispatch_event(
|
||||||
|
KeyDownEvent {
|
||||||
|
keystroke: Keystroke::parse(key).unwrap(),
|
||||||
|
is_held: false,
|
||||||
|
prefer_character_input: false,
|
||||||
|
}
|
||||||
|
.to_platform_input(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(*changes.borrow(), vec![0.0, 10.0, 20.0, 10.0]);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use gpui_native_core::checkbox::Checkbox;
|
|||||||
use gpui_native_core::common::{h_flex, v_flex};
|
use gpui_native_core::common::{h_flex, v_flex};
|
||||||
use gpui_native_core::link::Link;
|
use gpui_native_core::link::Link;
|
||||||
use gpui_native_core::radio::{Radio, RadioGroup};
|
use gpui_native_core::radio::{Radio, RadioGroup};
|
||||||
|
use gpui_native_core::slider::Slider;
|
||||||
use gpui_native_core::switch::Switch;
|
use gpui_native_core::switch::Switch;
|
||||||
use gpui_native_core::toggle::Toggle;
|
use gpui_native_core::toggle::Toggle;
|
||||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||||
@@ -21,6 +22,7 @@ struct Gallery {
|
|||||||
switch_checked: bool,
|
switch_checked: bool,
|
||||||
plan: SharedString,
|
plan: SharedString,
|
||||||
toggle_pressed: bool,
|
toggle_pressed: bool,
|
||||||
|
volume: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Gallery {
|
impl Render for Gallery {
|
||||||
@@ -362,6 +364,34 @@ impl Render for Gallery {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(section_label(
|
||||||
|
"slider · native refs — NSSlider, WinUI Slider, Adwaita Scale, Breeze Slider",
|
||||||
|
colors,
|
||||||
|
))
|
||||||
|
.child({
|
||||||
|
let gallery = gallery.clone();
|
||||||
|
Slider::new("gallery-slider", self.volume)
|
||||||
|
.min(0.0)
|
||||||
|
.max(100.0)
|
||||||
|
.step(5.0)
|
||||||
|
.accessibility_label("Volume")
|
||||||
|
.on_change(move |volume, _, cx| {
|
||||||
|
gallery.update(cx, |gallery, cx| {
|
||||||
|
gallery.volume = volume;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(format!("volume: {}", self.volume)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -517,6 +547,7 @@ fn main() {
|
|||||||
switch_checked: true,
|
switch_checked: true,
|
||||||
plan: SharedString::from("starter"),
|
plan: SharedString::from("starter"),
|
||||||
toggle_pressed: false,
|
toggle_pressed: false,
|
||||||
|
volume: 40.0,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user