From 6e0b541660217c8cd1f393360cbb444dedf2e5f8 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 9 Aug 2026 04:48:57 +0800 Subject: [PATCH] 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. --- crates/gpui_widgets/src/lib.rs | 1 + crates/gpui_widgets/src/slider/mod.rs | 826 ++++++++++++++++++++++++ crates/gpui_widgets/src/slider/model.rs | 387 +++++++++++ crates/gpui_widgets/src/value.rs | 2 +- 4 files changed, 1215 insertions(+), 1 deletion(-) create mode 100644 crates/gpui_widgets/src/slider/mod.rs create mode 100644 crates/gpui_widgets/src/slider/model.rs diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index b4aab935a3..e048bf7b58 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -18,4 +18,5 @@ //! covered by plain unit tests. pub mod keyable; +pub mod slider; pub mod value; diff --git a/crates/gpui_widgets/src/slider/mod.rs b/crates/gpui_widgets/src/slider/mod.rs new file mode 100644 index 0000000000..e86b895aa2 --- /dev/null +++ b/crates/gpui_widgets/src/slider/mod.rs @@ -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) -> 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, + /// 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>, + /// Focus-out subscription of the active editor, kept alive while editing. + edit_subscription: Option, + /// 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 { + 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> { + 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) { + 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) { + 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) { + 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) { + 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) { + 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 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) -> 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>>, _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>>, _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>, _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>, + _offset: Point, + window: &mut Window, + cx: &mut App, +) -> Entity { + if let Ok(mut drag) = drag.write() { + drag.start_y = f32::from(window.mouse_position().y); + } + cx.new(|_| SliderDragGhost) +} + +fn paint_slider( + bounds: Bounds, + _content: Bounds, + 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, + events: Vec, + } + + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .justify_center() + .child(self.slider.clone()) + } + } + + fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity) { + // `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, + event: &SliderEvent, + _cx: &mut Context| { + 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 { + events + .iter() + .filter_map(|event| match event { + SliderEvent::ValueChanged { value, .. } => Some(value.to_f64()), + _ => None, + }) + .collect() + } + + fn double_click(cx: &mut VisualTestContext, position: Point) { + 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 { + 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, 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); + } +} diff --git a/crates/gpui_widgets/src/slider/model.rs b/crates/gpui_widgets/src/slider/model.rs new file mode 100644 index 0000000000..59b9443234 --- /dev/null +++ b/crates/gpui_widgets/src/slider/model.rs @@ -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); + } +} diff --git a/crates/gpui_widgets/src/value.rs b/crates/gpui_widgets/src/value.rs index 8efe84e284..8f9440d13a 100644 --- a/crates/gpui_widgets/src/value.rs +++ b/crates/gpui_widgets/src/value.rs @@ -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