tessera_ui_basic_components/
slider.rs

1//! An interactive slider component for selecting a value in a range.
2//!
3//! ## Usage
4//!
5//! Use to allow users to select a value from a continuous range.
6use std::sync::Arc;
7
8use derive_builder::Builder;
9use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
10use tessera_ui::{
11    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, InputHandlerInput,
12    MeasureInput, MeasurementError, Px, PxPosition,
13    accesskit::{Action, Role},
14    focus_state::Focus,
15    tessera,
16    winit::window::CursorIcon,
17};
18
19use crate::{
20    shape_def::Shape,
21    surface::{SurfaceArgsBuilder, surface},
22};
23
24/// Stores the interactive state for the [`slider`] component, such as whether the slider is currently being dragged by the user.
25/// The [`SliderState`] handle owns the necessary locking internally, so callers can simply clone and pass it between components.
26pub(crate) struct SliderStateInner {
27    /// True if the user is currently dragging the slider.
28    pub is_dragging: bool,
29    /// The focus handler for the slider.
30    pub focus: Focus,
31}
32
33impl Default for SliderStateInner {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl SliderStateInner {
40    pub fn new() -> Self {
41        Self {
42            is_dragging: false,
43            focus: Focus::new(),
44        }
45    }
46}
47
48#[derive(Clone)]
49pub struct SliderState {
50    inner: Arc<RwLock<SliderStateInner>>,
51}
52
53impl SliderState {
54    pub fn new() -> Self {
55        Self {
56            inner: Arc::new(RwLock::new(SliderStateInner::new())),
57        }
58    }
59
60    pub(crate) fn read(&self) -> RwLockReadGuard<'_, SliderStateInner> {
61        self.inner.read()
62    }
63
64    pub(crate) fn write(&self) -> RwLockWriteGuard<'_, SliderStateInner> {
65        self.inner.write()
66    }
67
68    /// Returns whether the slider thumb is currently being dragged.
69    pub fn is_dragging(&self) -> bool {
70        self.inner.read().is_dragging
71    }
72
73    /// Manually sets the dragging flag. Useful for custom gesture integrations.
74    pub fn set_dragging(&self, dragging: bool) {
75        self.inner.write().is_dragging = dragging;
76    }
77
78    /// Requests focus for the slider.
79    pub fn request_focus(&self) {
80        self.inner.write().focus.request_focus();
81    }
82
83    /// Clears focus from the slider if it is currently focused.
84    pub fn clear_focus(&self) {
85        self.inner.write().focus.unfocus();
86    }
87
88    /// Returns `true` if this slider currently holds focus.
89    pub fn is_focused(&self) -> bool {
90        self.inner.read().focus.is_focused()
91    }
92}
93
94impl Default for SliderState {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100/// Arguments for the `slider` component.
101#[derive(Builder, Clone)]
102#[builder(pattern = "owned")]
103pub struct SliderArgs {
104    /// The current value of the slider, ranging from 0.0 to 1.0.
105    #[builder(default = "0.0")]
106    pub value: f32,
107
108    /// Callback function triggered when the slider's value changes.
109    #[builder(default = "Arc::new(|_| {})")]
110    pub on_change: Arc<dyn Fn(f32) + Send + Sync>,
111
112    /// The width of the slider track.
113    #[builder(default = "Dp(200.0)")]
114    pub width: Dp,
115
116    /// The height of the slider track.
117    #[builder(default = "Dp(12.0)")]
118    pub track_height: Dp,
119
120    /// The color of the active part of the track (progress fill).
121    #[builder(default = "Color::new(0.2, 0.5, 0.8, 1.0)")]
122    pub active_track_color: Color,
123
124    /// The color of the inactive part of the track (background).
125    #[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
126    pub inactive_track_color: Color,
127
128    /// Disable interaction.
129    #[builder(default = "false")]
130    pub disabled: bool,
131    /// Optional accessibility label read by assistive technologies.
132    #[builder(default, setter(strip_option, into))]
133    pub accessibility_label: Option<String>,
134    /// Optional accessibility description.
135    #[builder(default, setter(strip_option, into))]
136    pub accessibility_description: Option<String>,
137}
138
139/// Helper: check if a cursor position is within the bounds of a component.
140fn cursor_within_component(cursor_pos: Option<PxPosition>, computed: &ComputedData) -> bool {
141    if let Some(pos) = cursor_pos {
142        let within_x = pos.x.0 >= 0 && pos.x.0 < computed.width.0;
143        let within_y = pos.y.0 >= 0 && pos.y.0 < computed.height.0;
144        within_x && within_y
145    } else {
146        false
147    }
148}
149
150/// Helper: compute normalized progress (0.0..1.0) from cursor X and width.
151/// Returns None when cursor is not available.
152fn cursor_progress(cursor_pos: Option<PxPosition>, width_f: f32) -> Option<f32> {
153    cursor_pos.map(|pos| (pos.x.0 as f32 / width_f).clamp(0.0, 1.0))
154}
155
156fn handle_slider_state(input: &mut InputHandlerInput, state: &SliderState, args: &SliderArgs) {
157    if args.disabled {
158        return;
159    }
160
161    let is_in_component = cursor_within_component(input.cursor_position_rel, &input.computed_data);
162
163    if is_in_component {
164        input.requests.cursor_icon = CursorIcon::Pointer;
165    }
166
167    if !is_in_component && !state.read().is_dragging {
168        return;
169    }
170
171    let width_f = input.computed_data.width.0 as f32;
172    let mut new_value: Option<f32> = None;
173
174    handle_cursor_events(input, state, &mut new_value, width_f);
175    update_value_on_drag(input, state, &mut new_value, width_f);
176    notify_on_change(new_value, args);
177}
178
179fn handle_cursor_events(
180    input: &mut InputHandlerInput,
181    state: &SliderState,
182    new_value: &mut Option<f32>,
183    width_f: f32,
184) {
185    for event in input.cursor_events.iter() {
186        match &event.content {
187            CursorEventContent::Pressed(_) => {
188                {
189                    let mut inner = state.write();
190                    inner.focus.request_focus();
191                    inner.is_dragging = true;
192                }
193                if let Some(v) = cursor_progress(input.cursor_position_rel, width_f) {
194                    *new_value = Some(v);
195                }
196            }
197            CursorEventContent::Released(_) => {
198                state.write().is_dragging = false;
199            }
200            _ => {}
201        }
202    }
203}
204
205fn update_value_on_drag(
206    input: &InputHandlerInput,
207    state: &SliderState,
208    new_value: &mut Option<f32>,
209    width_f: f32,
210) {
211    if state.read().is_dragging
212        && let Some(v) = cursor_progress(input.cursor_position_rel, width_f)
213    {
214        *new_value = Some(v);
215    }
216}
217
218fn notify_on_change(new_value: Option<f32>, args: &SliderArgs) {
219    if let Some(v) = new_value
220        && (v - args.value).abs() > f32::EPSILON
221    {
222        (args.on_change)(v);
223    }
224}
225
226fn apply_slider_accessibility(
227    input: &mut InputHandlerInput<'_>,
228    args: &SliderArgs,
229    current_value: f32,
230    on_change: &Arc<dyn Fn(f32) + Send + Sync>,
231) {
232    let mut builder = input.accessibility().role(Role::Slider);
233
234    if let Some(label) = args.accessibility_label.as_ref() {
235        builder = builder.label(label.clone());
236    }
237    if let Some(description) = args.accessibility_description.as_ref() {
238        builder = builder.description(description.clone());
239    }
240
241    builder = builder
242        .numeric_value(current_value as f64)
243        .numeric_range(0.0, 1.0);
244
245    if args.disabled {
246        builder = builder.disabled();
247    } else {
248        builder = builder
249            .focusable()
250            .action(Action::Increment)
251            .action(Action::Decrement);
252    }
253
254    builder.commit();
255
256    if args.disabled {
257        return;
258    }
259
260    let value_for_handler = current_value;
261    let on_change = on_change.clone();
262    input.set_accessibility_action_handler(move |action| {
263        let new_value = match action {
264            Action::Increment => Some((value_for_handler + ACCESSIBILITY_STEP).clamp(0.0, 1.0)),
265            Action::Decrement => Some((value_for_handler - ACCESSIBILITY_STEP).clamp(0.0, 1.0)),
266            _ => None,
267        };
268
269        if let Some(new_value) = new_value
270            && (new_value - value_for_handler).abs() > f32::EPSILON
271        {
272            on_change(new_value);
273        }
274    });
275}
276
277fn render_track(args: &SliderArgs) {
278    surface(
279        SurfaceArgsBuilder::default()
280            .width(DimensionValue::Fixed(args.width.to_px()))
281            .height(DimensionValue::Fixed(args.track_height.to_px()))
282            .style(args.inactive_track_color.into())
283            .shape({
284                let radius = Dp(args.track_height.0 / 2.0);
285                Shape::RoundedRectangle {
286                    top_left: radius,
287                    top_right: radius,
288                    bottom_right: radius,
289                    bottom_left: radius,
290                    g2_k_value: 2.0, // Capsule shape
291                }
292            })
293            .build()
294            .unwrap(),
295        None,
296        move || {
297            render_progress_fill(args);
298        },
299    );
300}
301
302fn render_progress_fill(args: &SliderArgs) {
303    let progress_width = args.width.to_px().to_f32() * args.value;
304    surface(
305        SurfaceArgsBuilder::default()
306            .width(DimensionValue::Fixed(Px(progress_width as i32)))
307            .height(DimensionValue::Fill {
308                min: None,
309                max: None,
310            })
311            .style(args.active_track_color.into())
312            .shape({
313                let radius = Dp(args.track_height.0 / 2.0);
314                Shape::RoundedRectangle {
315                    top_left: radius,
316                    top_right: radius,
317                    bottom_right: radius,
318                    bottom_left: radius,
319                    g2_k_value: 2.0, // Capsule shape
320                }
321            })
322            .build()
323            .unwrap(),
324        None,
325        || {},
326    );
327}
328
329fn measure_slider(
330    input: &MeasureInput,
331    args: &SliderArgs,
332) -> Result<ComputedData, MeasurementError> {
333    let self_width = args.width.to_px();
334    let self_height = args.track_height.to_px();
335
336    let track_id = input.children_ids[0];
337
338    // Measure track
339    let track_constraint = Constraint::new(
340        DimensionValue::Fixed(self_width),
341        DimensionValue::Fixed(self_height),
342    );
343    input.measure_child(track_id, &track_constraint)?;
344    input.place_child(track_id, PxPosition::new(Px(0), Px(0)));
345
346    Ok(ComputedData {
347        width: self_width,
348        height: self_height,
349    })
350}
351
352/// # slider
353///
354/// Renders an interactive slider for selecting a value between 0.0 and 1.0.
355///
356/// ## Usage
357///
358/// Use for settings like volume or brightness, or for any user-adjustable value.
359///
360/// ## Parameters
361///
362/// - `args` — configures the slider's value, appearance, and callbacks; see [`SliderArgs`].
363/// - `state` — a clonable [`SliderState`] to manage interaction state like dragging and focus.
364///
365/// ## Examples
366///
367/// ```
368/// use std::sync::Arc;
369/// use tessera_ui::Dp;
370/// use tessera_ui_basic_components::slider::{slider, SliderArgsBuilder, SliderState};
371///
372/// // In a real application, you would manage this state.
373/// let slider_state = SliderState::new();
374///
375/// slider(
376///     SliderArgsBuilder::default()
377///         .width(Dp(200.0))
378///         .value(0.5)
379///         .on_change(Arc::new(|new_value| {
380///             // In a real app, you would update your state here.
381///             println!("Slider value changed to: {}", new_value);
382///         }))
383///         .build()
384///         .unwrap(),
385///     slider_state,
386/// );
387/// ```
388#[tessera]
389pub fn slider(args: impl Into<SliderArgs>, state: SliderState) {
390    let args: SliderArgs = args.into();
391
392    render_track(&args);
393
394    let cloned_args = args.clone();
395    let state_clone = state.clone();
396    input_handler(Box::new(move |mut input| {
397        handle_slider_state(&mut input, &state_clone, &cloned_args);
398        apply_slider_accessibility(
399            &mut input,
400            &cloned_args,
401            cloned_args.value,
402            &cloned_args.on_change,
403        );
404    }));
405
406    let cloned_args = args.clone();
407    measure(Box::new(move |input| measure_slider(input, &cloned_args)));
408}
409const ACCESSIBILITY_STEP: f32 = 0.05;