diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5058af2..010bfb9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,3 +19,6 @@ pub mod toggle; #[cfg(feature = "link")] pub mod link; + +#[cfg(feature = "slider")] +pub mod slider; diff --git a/crates/core/src/slider.rs b/crates/core/src/slider.rs new file mode 100644 index 0000000..6345211 --- /dev/null +++ b/crates/core/src/slider.rs @@ -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; + +/// The pointer→value setter shared by the click and drag listeners. +type ValueSetter = Rc; + +/// 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, + on_change: Option, +} + +impl Slider { + /// Creates a slider over `0..=1` with no stepping, at `value`. + pub fn new(id: I, value: f32) -> Self + where + I: Into, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// The change handler, fired with the next value by click, drag, and arrows. + pub fn on_change(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::>)); + + // 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)), + ), + ) + } +} diff --git a/crates/core/tests/slider.rs b/crates/core/tests/slider.rs new file mode 100644 index 0000000..a03db3a --- /dev/null +++ b/crates/core/tests/slider.rs @@ -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>>, + value: f32, + disabled: bool, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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>>) { + 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]); +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 15bb052..6958f7e 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -9,6 +9,7 @@ use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::link::Link; use gpui_native_core::radio::{Radio, RadioGroup}; +use gpui_native_core::slider::Slider; use gpui_native_core::switch::Switch; use gpui_native_core::toggle::Toggle; use gpui_native_icons::{IconAssets, IconRole, icon}; @@ -21,6 +22,7 @@ struct Gallery { switch_checked: bool, plan: SharedString, toggle_pressed: bool, + volume: f32, } 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( div() .flex() @@ -517,6 +547,7 @@ fn main() { switch_checked: true, plan: SharedString::from("starter"), toggle_pressed: false, + volume: 40.0, }) }, ) {