feat(gpui_widgets): add slider family with drag/double-click/wheel/reset gestures

Implements W1's slider: a pure SliderModel state machine (float, integer,
rational and angle kinds with clamping, step snapping, fine adjustment and
drag math) plus the Slider view with vertical-drag scrubbing (shift for
fine), double-click direct entry backed by editable_text (enter commits,
escape cancels, click-away commits, invalid text rejected), wheel stepping,
middle-click reset and arrow-key navigation. Every edit is emitted as a
SliderEvent request; keyable controls expose a Keying diamond. 37 unit and
interaction tests pass.
This commit is contained in:
2026-08-09 04:48:57 +08:00
parent df7c3d274f
commit 6e0b541660
4 changed files with 1215 additions and 1 deletions
+1
View File
@@ -18,4 +18,5 @@
//! covered by plain unit tests.
pub mod keyable;
pub mod slider;
pub mod value;
+826
View File
@@ -0,0 +1,826 @@
//! The slider family: float, integer, rational (fraction) and angle sliders.
//!
//! A [`Slider`] is a thin view over the pure [`SliderModel`] state machine.
//! Gestures:
//!
//! * **Drag vertically** (up increases) to change the value; hold `shift` for
//! fine adjustment. A drag starts once the cursor moves past a small
//! threshold, so plain clicks are still clicks.
//! * **Double-click** to type a value directly. `enter` commits, `escape`
//! cancels, and losing focus commits what was typed. Invalid text is
//! rejected and the previous value is kept.
//! * **Mouse wheel** steps the value (coarse, or fine with `shift`).
//! * **Middle-click** resets to the default.
//! * **Arrow keys** step when the slider is focused.
//!
//! Every change is emitted as [`SliderEvent::ValueChanged`]; the widget never
//! mutates engine state. Formatting/parsing is injectable via
//! [`ValueFormatter`](crate::value::ValueFormatter).
mod model;
use std::sync::{Arc, RwLock};
use gpui::{
App, BorderStyle, Bounds, ClickEvent, Context, Corners, DragMoveEvent, Edges, ElementId,
Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent, MouseButton, MouseDownEvent,
Pixels, Point, Render, ScrollWheelEvent, Subscription, Window, canvas, colors::DefaultColors,
div, fill, point, prelude::*, px, quad, size,
};
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
pub use model::SliderModel;
use crate::keyable::{KeyingRequest, KeyingState, keying_diamond, keying_request};
use crate::value::{SliderValue, ValueFormatter};
/// How far (in pixels) the cursor must move vertically to sweep the whole
/// value range. Larger values make the slider less sensitive.
const VERTICAL_DRAG_RANGE_PX: f32 = 100.0;
/// A request emitted by a slider.
#[derive(Debug, Clone, PartialEq)]
pub enum SliderEvent {
/// The value changed (drag, wheel, reset, typed commit).
ValueChanged {
/// The control's stable id.
control: usize,
/// The new value.
value: SliderValue,
},
/// A drag gesture started.
DragStarted {
/// The control's stable id.
control: usize,
},
/// A drag gesture ended.
DragFinished {
/// The control's stable id.
control: usize,
},
/// Direct text entry was committed (or the editor lost focus).
EditCommitted {
/// The control's stable id.
control: usize,
/// The accepted value.
value: SliderValue,
},
/// Direct text entry was cancelled (escape or rejected input).
EditCancelled {
/// The control's stable id.
control: usize,
},
/// The keying diamond was clicked.
Keying(KeyingRequest),
}
/// Transient payload carried by an in-flight drag gesture.
#[derive(Clone, Copy, Debug)]
struct SliderDrag {
/// Cursor Y at drag start (window coords, px).
start_y: f32,
}
/// Invisible ghost view that follows the cursor during a drag.
#[derive(Clone, Copy, Debug)]
struct SliderDragGhost;
impl Render for SliderDragGhost {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(0.0)).h(px(0.0))
}
}
/// A single form-control slider.
pub struct Slider {
/// Stable id used for element ids, events and keying requests.
control: usize,
/// The pure state machine.
model: SliderModel,
/// Injectable formatting/parsing.
formatter: Box<dyn ValueFormatter>,
/// The keying state reported at the current frame.
keying: KeyingState,
/// For arrow-key navigation.
focus_handle: FocusHandle,
/// Active direct-entry editor, if any.
editing: Option<Entity<EditableTextState>>,
/// Focus-out subscription of the active editor, kept alive while editing.
edit_subscription: Option<Subscription>,
/// Set by the escape path so a later focus-lost does not commit.
edit_cancelled: bool,
/// True while the user is dragging (so we can report drag start/finish).
dragging: bool,
}
impl Slider {
/// Create a slider for `control` over `model`.
pub fn new(
control: usize,
model: SliderModel,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
model,
formatter: Box::new(crate::value::DefaultFormatter),
keying: KeyingState::NoKey,
focus_handle: cx.focus_handle(),
editing: None,
edit_subscription: None,
edit_cancelled: false,
dragging: false,
}
}
/// Inject a custom formatter/parser for display and direct entry.
pub fn with_formatter(mut self, formatter: impl ValueFormatter) -> Self {
self.formatter = Box::new(formatter);
self
}
/// Report the control's keying state for the current frame.
pub fn with_keying(mut self, state: KeyingState) -> Self {
self.keying = state;
self
}
/// The current value.
pub fn value(&self) -> SliderValue {
self.model.value()
}
/// A copy of the pure model state.
pub fn model(&self) -> SliderModel {
self.model
}
/// Whether direct entry is currently active.
pub fn is_editing(&self) -> bool {
self.editing.is_some()
}
/// The active editor entity, if direct entry is open.
///
/// Hosts and tests can use this to seed or inspect the text being
/// edited (e.g. `editor.update(cx, |editor, cx| editor.emplace("0.7", cx))`).
pub fn editor(&self) -> Option<Entity<EditableTextState>> {
self.editing.clone()
}
/// Set the value directly (clamped and snapped by the model).
pub fn set_value(&mut self, value: SliderValue) {
self.model.set_value(value);
}
/// Emit `ValueChanged` if `changed` is true and repaint.
fn apply_and_notify(&mut self, changed: bool, cx: &mut Context<Self>) {
if changed {
cx.emit(SliderEvent::ValueChanged {
control: self.control,
value: self.model.value(),
});
cx.notify();
}
}
/// Begin double-click direct entry.
fn begin_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.editing.is_some() {
return;
}
let text = self.formatter.format(self.model.value());
let editor = cx.new(|cx| EditableTextState::new(StringStorage::from(text.to_string()), cx));
let focus_handle = editor.read(cx).focus_handle(cx);
let subscription = cx.on_focus_out(&focus_handle, window, |this, _event, _window, cx| {
// Clicking away commits what was typed, unless escape already
// cancelled the session.
if this.editing.is_some() {
if this.edit_cancelled {
this.cancel_edit(cx);
} else {
this.commit_edit(cx);
}
}
});
self.edit_cancelled = false;
self.edit_subscription = Some(subscription);
self.editing = Some(editor.clone());
window.focus(&focus_handle, cx);
cx.notify();
}
/// Commit the current editor text. Invalid text is rejected (the
/// previous value is kept) and the editor closes either way.
fn commit_edit(&mut self, cx: &mut Context<Self>) {
if let Some(editor) = self.editing.take() {
self.edit_subscription = None;
let text = editor.read(cx).as_str().to_string();
match self.formatter.parse(&text) {
Ok(value) => {
let changed = self.model.set_value(value);
self.apply_and_notify(changed, cx);
cx.emit(SliderEvent::EditCommitted {
control: self.control,
value: self.model.value(),
});
}
Err(_) => {
cx.emit(SliderEvent::EditCancelled {
control: self.control,
});
}
}
cx.notify();
}
}
/// Cancel direct entry, keeping the previous value.
fn cancel_edit(&mut self, cx: &mut Context<Self>) {
if self.editing.take().is_some() {
self.edit_subscription = None;
cx.emit(SliderEvent::EditCancelled {
control: self.control,
});
cx.notify();
}
}
/// Apply a wheel step.
fn apply_scroll(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
let delta = event.delta.pixel_delta(px(16.0));
let steps = (f32::from(delta.y) / 16.0).round() as i32;
if steps == 0 {
return;
}
let fine = event.modifiers.shift;
let changed = self.model.apply_step(steps, fine);
self.apply_and_notify(changed, cx);
}
}
impl EventEmitter<SliderEvent> for Slider {}
impl Focusable for Slider {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Slider {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let fraction = self.model.fraction();
let drag_payload = Arc::new(RwLock::new(SliderDrag { start_y: 0.0 }));
let control = self.control;
let mut track = div()
.id(ElementId::named_usize("gpui-widgets-slider-track", control))
.flex_1()
.h(px(18.0))
.relative()
.rounded_md()
.bg(colors.container)
.track_focus(&self.focus_handle)
.cursor_pointer()
.debug_selector(|| "slider-track".into())
.on_drag(drag_payload.clone(), slider_drag_ghost)
.on_drag_move(
cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<SliderDrag>>>, _window, cx| {
let drag = event.drag(cx).clone();
let drag = drag.read().unwrap();
let dy = f32::from(event.event.position.y) - drag.start_y;
let fine = event.event.modifiers.shift;
let changed = this.model.drag_delta(dy, VERTICAL_DRAG_RANGE_PX, fine);
this.apply_and_notify(changed, cx);
},
),
)
.on_drag_move(
cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<SliderDrag>>>, _window, cx| {
if !this.dragging {
this.dragging = true;
cx.emit(SliderEvent::DragStarted {
control: this.control,
});
}
let _ = event;
cx.notify();
},
),
)
.on_drop(cx.listener(|this, _drag: &Arc<RwLock<SliderDrag>>, _window, cx| {
if this.dragging {
this.dragging = false;
cx.emit(SliderEvent::DragFinished {
control: this.control,
});
}
}))
.on_click(cx.listener(|this, event: &ClickEvent, window, cx| {
if event.click_count() >= 2 {
this.begin_edit(window, cx);
}
}))
.on_mouse_down(
MouseButton::Middle,
cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
let changed = this.model.reset();
this.apply_and_notify(changed, cx);
}),
)
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
this.apply_scroll(event, cx);
}))
.on_key_down(
cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"left" | "down" => {
let changed =
this.model.apply_step(-1, event.keystroke.modifiers.shift);
this.apply_and_notify(changed, cx);
}
"right" | "up" => {
let changed = this.model.apply_step(1, event.keystroke.modifiers.shift);
this.apply_and_notify(changed, cx);
}
_ => {}
}
}),
)
.child(canvas(
move |bounds, _window, _cx| bounds,
move |bounds, content, window, cx| {
paint_slider(bounds, content, fraction, window, cx);
},
));
// Direct-entry editor overlay while editing.
if let Some(editor) = self.editing.clone() {
let weak = editor.downgrade();
track = track
.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.flex()
.items_center()
.px_1()
.bg(colors.background)
.on_mouse_down_out(
cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
// Clicking outside the editor commits.
this.commit_edit(cx);
}),
)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"enter" => this.commit_edit(cx),
"escape" => {
this.edit_cancelled = true;
this.cancel_edit(cx);
}
_ => {}
}
}))
.child(
text_input(ElementId::named_usize(
"gpui-widgets-slider-edit-input",
control,
))
.state(weak)
.accepts_input(true),
),
);
}
div()
.w_full()
.flex()
.items_center()
.gap(px(6.0))
.child(track)
.child(
keying_diamond(self.control, self.keying).on_click(
cx.listener(|this, _event: &ClickEvent, _window, cx| {
cx.emit(SliderEvent::Keying(keying_request(
this.control,
this.keying,
)));
cx.stop_propagation();
}),
),
)
}
}
/// Build the (invisible) ghost view that accompanies a slider drag.
fn slider_drag_ghost(
drag: &Arc<RwLock<SliderDrag>>,
_offset: Point<Pixels>,
window: &mut Window,
cx: &mut App,
) -> Entity<SliderDragGhost> {
if let Ok(mut drag) = drag.write() {
drag.start_y = f32::from(window.mouse_position().y);
}
cx.new(|_| SliderDragGhost)
}
fn paint_slider(
bounds: Bounds<Pixels>,
_content: Bounds<Pixels>,
fraction: f64,
window: &mut Window,
cx: &mut App,
) {
use gpui::Hsla;
let colors = cx.default_colors().clone();
let center_y = px(f32::from(bounds.top()) + f32::from(bounds.size.height) / 2.0);
// Track.
let track_bounds = Bounds::new(
point(bounds.left(), center_y - px(2.0)),
size(bounds.size.width, px(4.0)),
);
window.paint_quad(fill(track_bounds, Hsla::from(colors.border)));
// Active fill up to the fraction.
let width = f32::from(bounds.size.width);
let fill_width = (width * fraction as f32).clamp(0.0, width);
if fill_width > 0.0 {
let fill_bounds = Bounds::new(
point(bounds.left(), track_bounds.top()),
size(px(fill_width), track_bounds.size.height),
);
window.paint_quad(fill(fill_bounds, Hsla::from(colors.selected)));
}
// Handle: a small circle at the fraction position.
let handle_radius = 5.0_f32;
let handle_bounds = Bounds::new(
point(bounds.left() + px(fill_width - handle_radius), center_y - px(handle_radius)),
size(px(handle_radius * 2.0), px(handle_radius * 2.0)),
);
window.paint_quad(quad(
handle_bounds,
Corners::all(px(handle_radius)),
Hsla::from(colors.text),
Edges::all(px(1.0)),
Hsla::from(colors.border),
BorderStyle::Solid,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::ValueKind;
use gpui::{Modifiers, MouseUpEvent, ScrollDelta, TestAppContext, VisualTestContext};
#[derive(Clone)]
struct Host {
slider: Entity<Slider>,
events: Vec<SliderEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.flex_col()
.justify_center()
.child(self.slider.clone())
}
}
fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
// `default_colors()` (used by the widgets) requires the global to be
// initialized; the test platform does not do it automatically.
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(100.0)), |window, cx| {
let slider = cx.new(|cx| {
Slider::new(
1,
SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 0.5),
window,
cx,
)
});
let host = Host {
slider,
events: Vec::new(),
};
cx.subscribe(
&host.slider,
|host: &mut Host,
_slider: Entity<Slider>,
event: &SliderEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
(cx, host)
}
fn value_events(events: &[SliderEvent]) -> Vec<f64> {
events
.iter()
.filter_map(|event| match event {
SliderEvent::ValueChanged { value, .. } => Some(value.to_f64()),
_ => None,
})
.collect()
}
fn double_click(cx: &mut VisualTestContext, position: Point<Pixels>) {
let modifiers = Modifiers::none();
cx.simulate_event(MouseDownEvent {
position,
modifiers,
button: MouseButton::Left,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(MouseUpEvent {
position,
modifiers,
button: MouseButton::Left,
click_count: 2,
});
cx.run_until_parked();
// Render the editor overlay so its input handler is registered.
redraw(cx);
}
/// A point comfortably inside the track (row is vertically centered).
fn track_point() -> Point<Pixels> {
point(px(200.0), px(50.0))
}
/// The test platform does not redraw dirty windows on its own, so state
/// changes (which only call `notify`) do not repaint until we force a
/// draw. This is required for freshly-created elements (e.g. the editor
/// overlay) to register their listeners and input handlers.
fn redraw(cx: &mut VisualTestContext) {
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
/// Compare a slider value to an expected float with tolerance (stepping
/// through non-binary steps like 0.1 accumulates representation error).
fn assert_value_close(actual: SliderValue, expected: f64) {
assert!(
(actual.to_f64() - expected).abs() < 1e-6,
"expected {expected}, got {actual:?}"
);
}
#[gpui::test]
async fn track_hitbox_is_rendered(cx: &mut TestAppContext) {
let (cx, _host) = make_host(cx);
let bounds = cx.debug_bounds("slider-track");
assert!(
bounds.is_some(),
"slider track was not painted into the rendered frame"
);
if let Some(bounds) = bounds {
eprintln!("track bounds: {bounds:?}, track point: {:?}", track_point());
assert!(bounds.contains(&track_point()));
}
}
/// Seed the active editor with `text` (the test platform cannot deliver
/// IME characters, so tests set the text directly and exercise the
/// commit/cancel paths).
fn seed_editor_text(cx: &mut VisualTestContext, host: &Entity<Host>, text: &str) {
let editor = cx
.read(|app| host.read(app).slider.read(app).editor())
.expect("editor should be open");
// `cx.update` on VisualTestContext takes a window closure; use the
// underlying TestAppContext to get at the app directly.
cx.cx.update(|app| {
editor.update(app, |editor, cx| editor.emplace(text, cx));
});
}
#[gpui::test]
async fn double_click_opens_editor_and_enter_commits(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
assert!(cx.read(|app| host.read(app).slider.read(app).is_editing()));
seed_editor_text(&mut cx, &host, "0.7");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let (value, is_editing, committed) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events.iter().any(|e| {
matches!(e, SliderEvent::EditCommitted { value, .. } if (value.to_f64() - 0.7).abs() < 1e-6)
}),
)
});
assert!(!is_editing);
assert_value_close(value, 0.7);
assert!(committed);
}
#[gpui::test]
async fn double_click_rejects_invalid_text(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
seed_editor_text(&mut cx, &host, "abc");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let (value, is_editing, cancelled) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::EditCancelled { .. })),
)
});
assert!(!is_editing);
// The previous value is kept.
assert_eq!(value, SliderValue::Float(0.5));
assert!(cancelled);
}
#[gpui::test]
async fn escape_cancels_editing(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
cx.simulate_keystrokes("escape");
cx.run_until_parked();
let (value, is_editing, cancelled) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::EditCancelled { .. })),
)
});
assert!(!is_editing);
assert_eq!(value, SliderValue::Float(0.5));
assert!(cancelled);
}
#[gpui::test]
async fn middle_click_resets_to_default(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Move the value first.
cx.simulate_event(ScrollWheelEvent {
position: track_point(),
delta: ScrollDelta::Pixels(point(px(0.0), px(-80.0))),
..Default::default()
});
cx.run_until_parked();
{
let moved = cx.read(|app| host.read(app).slider.read(app).value().to_f64());
assert!((moved - 0.5).abs() > 0.01, "value should have moved, got {moved}");
}
// Middle-click resets.
cx.simulate_mouse_down(track_point(), MouseButton::Middle, Modifiers::none());
cx.simulate_mouse_up(track_point(), MouseButton::Middle, Modifiers::none());
cx.run_until_parked();
let (value, events) = cx.read(|app| {
let host = host.read(app);
(host.slider.read(app).value(), host.events.clone())
});
assert_eq!(value, SliderValue::Float(0.5));
assert!(value_events(&events).contains(&0.5));
}
#[gpui::test]
async fn wheel_steps_value(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Scrolling up (positive y) increases the value.
cx.simulate_event(ScrollWheelEvent {
position: track_point(),
delta: ScrollDelta::Pixels(point(px(0.0), px(80.0))),
..Default::default()
});
cx.run_until_parked();
let (value, events) = cx.read(|app| {
let host = host.read(app);
(host.slider.read(app).value(), host.events.clone())
});
// 80px at 16px per notch = 5 coarse steps of 0.1, clamped to max.
assert_value_close(value, 1.0);
assert!(!value_events(&events).is_empty());
}
#[gpui::test]
async fn vertical_drag_changes_value(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Press on the track, then drag up. The first move past the drag
// threshold anchors the gesture; the second move applies the delta.
let modifiers = Modifiers::none();
cx.simulate_mouse_down(track_point(), MouseButton::Left, modifiers);
cx.simulate_mouse_move(point(px(200.0), px(45.0)), MouseButton::Left, modifiers);
cx.simulate_mouse_move(point(px(200.0), px(25.0)), MouseButton::Left, modifiers);
// Release inside the track so the drop listener on the slider fires.
cx.simulate_mouse_up(point(px(200.0), px(45.0)), MouseButton::Left, modifiers);
cx.run_until_parked();
let (value, started, finished) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value().to_f64(),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::DragStarted { .. })),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::DragFinished { .. })),
)
});
// 20px / 100px of range = +0.2 over the default 0.5.
assert!((value - 0.7).abs() < 0.001, "expected ~0.7, got {value}");
assert!(started);
assert!(finished);
}
#[gpui::test]
async fn focused_slider_steps_with_arrow_keys(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Click once to focus the slider (click_count 1 is a no-op for value).
cx.simulate_click(track_point(), Modifiers::none());
cx.simulate_keystrokes("right");
cx.run_until_parked();
let (value, events) = cx.read(|app| {
let host = host.read(app);
(host.slider.read(app).value(), host.events.clone())
});
assert_value_close(value, 0.6);
assert!(value_events(&events).iter().any(|v| (v - 0.6).abs() < 1e-6));
}
#[gpui::test]
async fn keying_diamond_emits_request(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// The diamond sits at the right edge of the row.
cx.simulate_click(point(px(390.0), px(50.0)), Modifiers::none());
cx.run_until_parked();
let requested = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, SliderEvent::Keying(request) if request.control == 1)
})
});
assert!(requested);
}
#[gpui::test]
async fn clicking_away_commits_editor(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
// 0.3 is exactly representable at the slider's 0.1 step.
seed_editor_text(&mut cx, &host, "0.3");
// Clicking outside the editor overlay commits the typed value.
cx.simulate_click(point(px(200.0), px(90.0)), Modifiers::none());
cx.run_until_parked();
let (value, is_editing, committed) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events.iter().any(|e| {
matches!(e, SliderEvent::EditCommitted { value, .. } if (value.to_f64() - 0.3).abs() < 1e-6)
}),
)
});
assert!(!is_editing);
assert_value_close(value, 0.3);
assert!(committed);
}
}
+387
View File
@@ -0,0 +1,387 @@
//! Pure slider state machine: range, stepping, clamping and drag math.
//!
//! This module has no gpui coupling so every behavior is unit-testable
//! (see the test module at the bottom). The [`Slider`](crate::slider::Slider)
//! view drives these methods from mouse/wheel/keyboard gestures.
use crate::value::{RationalValue, SliderValue, ValueKind};
/// The pure state of a slider.
///
/// The canonical numeric position is [`SliderModel::raw`]. For
/// [`ValueKind::Rational`] the raw value is the *numerator* over
/// [`SliderModel::rational_den`], so rational values never pass through
/// floating point.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SliderModel {
/// The family of values this slider edits.
pub kind: ValueKind,
/// Canonical numeric position (for `Rational`: the numerator).
pub raw: f64,
/// Inclusive lower bound of `raw`.
pub min: f64,
/// Inclusive upper bound of `raw`.
pub max: f64,
/// Coarse increment (wheel notch / arrow key).
pub step: f64,
/// Fine increment used with a fine-adjust modifier.
pub fine_step: f64,
/// Value restored by a middle-click reset.
pub default_raw: f64,
/// Denominator for `Rational` values (unused otherwise).
pub rational_den: i64,
}
impl SliderModel {
/// Create a slider over `[min, max]` with `step` increments.
///
/// For `Rational` sliders `min`, `max`, `step` and `default_raw` are in
/// numerator units; pair with [`Self::with_rational_den`].
pub fn new(kind: ValueKind, min: f64, max: f64, step: f64, default_raw: f64) -> Self {
let raw = default_raw.clamp(min, max);
Self {
kind,
raw,
min,
max,
step,
fine_step: step / 10.0,
default_raw: default_raw.clamp(min, max),
rational_den: 1,
}
}
/// Set the denominator of a `Rational` slider (a no-op otherwise).
pub fn with_rational_den(mut self, den: i64) -> Self {
if self.kind == ValueKind::Rational {
self.rational_den = den.max(1);
}
self
}
/// Override the fine-adjust increment.
pub fn with_fine_step(mut self, fine_step: f64) -> Self {
self.fine_step = fine_step;
self
}
/// The current value.
pub fn value(&self) -> SliderValue {
match self.kind {
ValueKind::Float => SliderValue::Float(self.raw),
ValueKind::Integer => SliderValue::Integer(self.raw as i64),
ValueKind::Rational => {
SliderValue::Rational(RationalValue::new_or_one(self.raw as i64, self.rational_den))
}
ValueKind::Angle => SliderValue::Angle(self.raw),
}
}
/// True if `v` is within `[min, max]` (and finite).
pub fn in_range(&self, v: f64) -> bool {
v.is_finite() && v >= self.min && v <= self.max
}
/// Clamp a raw position to `[min, max]`.
pub fn clamp(&self, raw: f64) -> f64 {
raw.clamp(self.min, self.max)
}
/// Snap a raw position to the nearest multiple of `step`, measured from
/// `min`. Positions outside the range snap back to the bounds.
fn snap_with_step(&self, raw: f64, step: f64) -> f64 {
let raw = self.clamp(raw);
if step <= 0.0 || self.spanned() == 0.0 {
return raw;
}
let steps = ((raw - self.min) / step).round();
self.clamp(self.min + steps * step)
}
/// Snap a raw position to the nearest coarse step, measured from `min`.
pub fn snap(&self, raw: f64) -> f64 {
self.snap_with_step(raw, self.step)
}
/// The width of the range (`max - min`, never negative).
pub fn spanned(&self) -> f64 {
(self.max - self.min).max(0.0)
}
/// Set the position from a raw value: clamps and snaps to `step`,
/// returns whether the position changed.
pub fn apply_with_step(&mut self, raw: f64, step: f64) -> bool {
if !raw.is_finite() {
return false;
}
let snapped = self.snap_with_step(raw, step);
if (snapped - self.raw).abs() > f64::EPSILON {
self.raw = snapped;
true
} else {
false
}
}
/// Set the position from a raw value: clamps and snaps to the coarse
/// step, returns whether the position changed.
pub fn apply_raw(&mut self, raw: f64) -> bool {
self.apply_with_step(raw, self.step)
}
/// Set the value, converting between representations where possible.
/// Returns whether the position changed. Non-finite input is rejected.
pub fn set_value(&mut self, value: SliderValue) -> bool {
let finite = match value {
SliderValue::Float(v) | SliderValue::Angle(v) => v.is_finite(),
SliderValue::Integer(_) | SliderValue::Rational(_) => true,
};
if !finite {
return false;
}
let raw = match (self.kind, value) {
(ValueKind::Float, SliderValue::Float(v))
| (ValueKind::Angle, SliderValue::Angle(v)) => v,
(ValueKind::Integer, SliderValue::Integer(v)) => v as f64,
(ValueKind::Rational, SliderValue::Rational(v)) => {
(v.num() as f64) * (self.rational_den as f64) / (v.den() as f64)
}
// Cross-kind conversions go through the numeric projection.
(kind, value) => {
let v = value.to_f64();
match kind {
ValueKind::Integer => v.trunc(),
ValueKind::Rational => {
(v * self.rational_den as f64).round() / self.rational_den as f64
}
_ => v,
}
}
};
self.apply_raw(raw)
}
/// The next position `delta` coarse (or fine) steps away from `from`,
/// clamped to the range.
pub fn step_from(&self, from: f64, delta: i32, fine: bool) -> f64 {
let step = if fine { self.fine_step } else { self.step };
if step <= 0.0 {
return self.clamp(from);
}
self.clamp(from + delta as f64 * step)
}
/// Step the current position by `delta` steps, snapping to the effective
/// (coarse or fine) step.
pub fn apply_step(&mut self, delta: i32, fine: bool) -> bool {
let step = if fine { self.fine_step } else { self.step };
self.apply_with_step(self.step_from(self.raw, delta, fine), step)
}
/// Reset to the default position; returns whether it changed.
pub fn reset(&mut self) -> bool {
let snapped = self.snap(self.default_raw);
if (snapped - self.raw).abs() > f64::EPSILON {
self.raw = snapped;
true
} else {
false
}
}
/// Whether the current position equals the (snapped) default.
pub fn is_at_default(&self) -> bool {
(self.snap(self.default_raw) - self.raw).abs() <= f64::EPSILON
}
/// Normalized position in `0..=1` for painting. Returns `0.5` when the
/// range is empty so the handle never leaves the track.
pub fn fraction(&self) -> f64 {
if self.spanned() == 0.0 {
return 0.5;
}
((self.raw - self.min) / self.spanned()).clamp(0.0, 1.0)
}
/// Set the position from a normalized `0..=1` fraction of the range.
pub fn set_fraction(&mut self, t: f64) -> bool {
if !t.is_finite() {
return false;
}
let raw = self.min + t.clamp(0.0, 1.0) * self.spanned();
self.apply_raw(raw)
}
/// Apply a vertical drag of `dy_px` pixels over a track that spans
/// `range_px` pixels. Dragging up (`dy_px < 0`) increases the value.
/// `fine` scales the motion by `1/10` and snaps to the fine step.
pub fn drag_delta(&mut self, dy_px: f32, range_px: f32, fine: bool) -> bool {
if range_px <= 0.0 {
return false;
}
let scale = if fine { 0.1 } else { 1.0 };
let step = if fine { self.fine_step } else { self.step };
let delta = -(dy_px as f64) / range_px as f64 * self.spanned() * scale;
self.apply_with_step(self.raw + delta, step)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn float_slider() -> SliderModel {
SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 0.5)
}
#[test]
fn new_clamps_default() {
let model = SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 5.0);
assert_eq!(model.raw, 1.0);
assert!(model.is_at_default());
}
#[test]
fn clamps_to_range() {
let model = float_slider();
assert_eq!(model.clamp(-2.0), 0.0);
assert_eq!(model.clamp(2.0), 1.0);
assert!(model.in_range(0.5));
assert!(!model.in_range(1.5));
assert!(!model.in_range(f64::NAN));
}
#[test]
fn snaps_to_step_from_min() {
let model = float_slider();
assert!((model.snap(0.27) - 0.3).abs() < 1e-9);
assert!((model.snap(0.24) - 0.2).abs() < 1e-9);
// Off-range values snap back to the bounds.
assert!((model.snap(-5.0) - 0.0).abs() < 1e-9);
assert!((model.snap(5.0) - 1.0).abs() < 1e-9);
}
#[test]
fn snaps_relative_to_nonzero_min() {
let model = SliderModel::new(ValueKind::Float, 0.5, 2.5, 0.5, 1.0);
// (1.15 - 0.5) / 0.5 = 1.3 -> round to 1 -> 0.5 + 0.5 = 1.0
assert!((model.snap(1.15) - 1.0).abs() < 1e-9);
// (1.35 - 0.5) / 0.5 = 1.7 -> round to 2 -> 1.5
assert!((model.snap(1.35) - 1.5).abs() < 1e-9);
}
#[test]
fn apply_raw_rejects_nan() {
let mut model = float_slider();
assert!(!model.apply_raw(f64::NAN));
assert_eq!(model.raw, 0.5);
}
#[test]
fn step_from_clamps() {
let model = float_slider();
assert!((model.step_from(0.95, 1, false) - 1.0).abs() < 1e-9);
assert!((model.step_from(0.05, -1, false) - 0.0).abs() < 1e-9);
assert!((model.step_from(0.5, 1, false) - 0.6).abs() < 1e-9);
// Fine step is 1/10 of the coarse step.
assert!((model.step_from(0.5, 1, true) - 0.51).abs() < 1e-9);
}
#[test]
fn reset_restores_default() {
let mut model = float_slider();
model.apply_raw(0.9);
assert!(!model.is_at_default());
assert!(model.reset());
assert_eq!(model.raw, 0.5);
assert!(model.is_at_default());
}
#[test]
fn fraction_and_set_fraction_are_inverse() {
let mut model = float_slider();
assert_eq!(model.fraction(), 0.5);
model.set_fraction(0.0);
assert_eq!(model.raw, 0.0);
model.set_fraction(1.0);
assert_eq!(model.raw, 1.0);
// Positions are quantized to the step: 0.25 lands on the nearest
// representable value (0.3 with a 0.1 step).
model.set_fraction(0.25);
assert!((model.raw - 0.3).abs() < 1e-9);
assert!((model.fraction() - 0.3).abs() < 1e-9);
}
#[test]
fn fraction_when_range_is_empty() {
let model = SliderModel::new(ValueKind::Float, 1.0, 1.0, 0.1, 1.0);
assert_eq!(model.fraction(), 0.5);
}
#[test]
fn drag_up_increases_value() {
let mut model = float_slider();
// 10px up over a 100px track spans 10% of the range.
assert!(model.drag_delta(-10.0, 100.0, false));
assert!((model.raw - 0.6).abs() < 1e-9);
// Fine drag moves 10x less.
let mut fine = float_slider();
fine.drag_delta(-10.0, 100.0, true);
assert!((fine.raw - 0.51).abs() < 1e-9);
// Dragging up past the max clamps.
let mut model = float_slider();
model.drag_delta(-1000.0, 100.0, false);
assert_eq!(model.raw, 1.0);
}
#[test]
fn integer_kind_round_trips_exactly() {
let mut model = SliderModel::new(ValueKind::Integer, -10.0, 10.0, 1.0, 0.0);
assert_eq!(model.value(), SliderValue::Integer(0));
model.set_value(SliderValue::Integer(5));
assert_eq!(model.value(), SliderValue::Integer(5));
model.apply_step(1, false);
assert_eq!(model.value(), SliderValue::Integer(6));
// A float value converts by truncation.
model.set_value(SliderValue::Float(3.9));
assert_eq!(model.value(), SliderValue::Integer(3));
}
#[test]
fn rational_kind_stays_exact() {
let mut model = SliderModel::new(ValueKind::Rational, 0.0, 24.0, 1.0, 0.0)
.with_rational_den(24);
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(0, 1).unwrap()));
model.apply_raw(1.0);
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(1, 24).unwrap()));
model.apply_raw(24.0);
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(1, 1).unwrap()));
// Setting a rational with a different denominator rescales.
model.set_value(SliderValue::Rational(RationalValue::new(1, 2).unwrap()));
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(1, 2).unwrap()));
}
#[test]
fn angle_kind_round_trips() {
let mut model = SliderModel::new(ValueKind::Angle, 0.0, 360.0, 0.5, 45.0);
assert_eq!(model.value(), SliderValue::Angle(45.0));
model.set_value(SliderValue::Angle(270.5));
assert_eq!(model.value(), SliderValue::Angle(270.5));
}
#[test]
fn set_value_rejects_non_finite() {
let mut model = float_slider();
assert!(!model.set_value(SliderValue::Float(f64::NAN)));
assert!(!model.set_value(SliderValue::Angle(f64::INFINITY)));
assert_eq!(model.raw, 0.5);
}
#[test]
fn integer_fine_step_is_whole() {
let model = SliderModel::new(ValueKind::Integer, 0.0, 10.0, 1.0, 5.0);
// Fine step for integers is 1/10 by default; snapping keeps it whole.
let snapped = model.snap(model.step_from(5.0, 1, true));
assert_eq!(snapped, 5.0);
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ pub enum ParseValueError {
/// Implementations are injected into controls so hosts can localize or
/// specialize the representation (e.g. timecode instead of frames, or
/// fractions with a fixed denominator).
pub trait ValueFormatter: 'static + Clone {
pub trait ValueFormatter: 'static {
/// Render a value as text.
fn format(&self, value: SliderValue) -> SharedString;
/// Parse text into a value. Must reject malformed input with