tessera_ui_basic_components/
slider.rs

1//! A reusable, interactive slider UI component for selecting a value within the range [0.0, 1.0].
2//!
3//! This module provides a customizable horizontal slider, suitable for use in forms, settings panels,
4//! media controls, or any scenario where users need to adjust a continuous value. The slider supports
5//! mouse and keyboard interaction, visual feedback for dragging and focus, and allows full control over
6//! appearance and behavior via configuration options and callbacks.
7//!
8//! Typical use cases include volume controls, progress bars, brightness adjustments, and other parameter selection tasks.
9//!
10//! The slider is fully controlled: you provide the current value and handle updates via a callback.
11//! State management (e.g., dragging, focus) is handled externally and passed in, enabling integration with various UI frameworks.
12//!
13//! See [`SliderArgs`] and [`SliderState`] for configuration and state management details.
14
15use std::sync::Arc;
16
17use derive_builder::Builder;
18use parking_lot::Mutex;
19use tessera_ui::{
20    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, Px, PxPosition,
21    focus_state::Focus, winit::window::CursorIcon,
22};
23use tessera_ui_macros::tessera;
24
25use crate::{
26    shape_def::Shape,
27    surface::{SurfaceArgsBuilder, surface},
28};
29
30///
31/// Stores the interactive state for the [`slider`] component, such as whether the slider is currently being dragged by the user.
32/// This struct should be managed via [`Arc<Mutex<SliderState>>`] and passed to the [`slider`] function to enable correct interaction handling.
33///
34/// # Fields
35/// - `is_dragging`: Indicates whether the user is actively dragging the slider thumb.
36/// - `focus`: Manages keyboard focus for the slider component.
37///
38/// Typically, you create and manage this state using [`use_state`] or similar state management utilities.
39///
40/// [`slider`]: crate::slider
41pub struct SliderState {
42    /// True if the user is currently dragging the slider.
43    pub is_dragging: bool,
44    /// The focus handler for the slider.
45    pub focus: Focus,
46}
47
48impl Default for SliderState {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl SliderState {
55    pub fn new() -> Self {
56        Self {
57            is_dragging: false,
58            focus: Focus::new(),
59        }
60    }
61}
62
63/// Arguments for the `slider` component.
64#[derive(Builder, Clone)]
65#[builder(pattern = "owned")]
66pub struct SliderArgs {
67    /// The current value of the slider, ranging from 0.0 to 1.0.
68    #[builder(default = "0.0")]
69    pub value: f32,
70
71    /// Callback function triggered when the slider's value changes.
72    #[builder(default = "Arc::new(|_| {})")]
73    pub on_change: Arc<dyn Fn(f32) + Send + Sync>,
74
75    /// The width of the slider track.
76    #[builder(default = "Dp(200.0)")]
77    pub width: Dp,
78
79    /// The height of the slider track.
80    #[builder(default = "Dp(12.0)")]
81    pub track_height: Dp,
82
83    /// The color of the active part of the track (progress fill).
84    #[builder(default = "Color::new(0.2, 0.5, 0.8, 1.0)")]
85    pub active_track_color: Color,
86
87    /// The color of the inactive part of the track (background).
88    #[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
89    pub inactive_track_color: Color,
90
91    /// Disable interaction.
92    #[builder(default = "false")]
93    pub disabled: bool,
94}
95
96#[tessera]
97///
98/// Renders a slider UI component that allows users to select a value in the range `[0.0, 1.0]`.
99///
100/// The slider displays a horizontal track with a draggable thumb. The current value is visually represented by the filled portion of the track.
101/// The component is fully controlled: you must provide the current value and a callback to handle value changes.
102///
103/// # Parameters
104/// - `args`: Arguments for configuring the slider. See [`SliderArgs`] for all options. The most important are:
105///   - `value` (`f32`): The current value of the slider, in the range `[0.0, 1.0]`.
106///   - `on_change` (`Arc<dyn Fn(f32) + Send + Sync>`): Callback invoked when the user changes the slider's value.
107///   - `width`, `track_height`, `active_track_color`, `inactive_track_color`, `disabled`: Appearance and interaction options.
108/// - `state`: Shared state for the slider, used to track interaction (e.g., dragging, focus). Create and manage this using [`use_state`] or similar, and pass it to the slider for correct behavior.
109///
110/// # State Management
111/// The `state` parameter must be an [`Arc<Mutex<SliderState>>`]. You can create and manage it using the `use_state` hook or any other state management approach compatible with your application.
112///
113/// # Example
114/// ```
115/// use std::sync::Arc;
116/// use parking_lot::Mutex;
117/// use tessera_ui::Dp;
118/// use tessera_ui_basic_components::slider::{slider, SliderArgs, SliderState, SliderArgsBuilder};
119///
120/// // In a real application, you would manage the state.
121/// let slider_state = Arc::new(Mutex::new(SliderState::new()));
122///
123/// // Create a slider with a width of 200dp and an initial value of 0.5.
124/// slider(
125///     SliderArgsBuilder::default()
126///         .width(Dp(200.0))
127///         .value(0.5)
128///         .on_change(Arc::new(|new_value| {
129///             // Update your application state here.
130///             println!("Slider value: {}", new_value);
131///         }))
132///         .build()
133///         .unwrap(),
134///     slider_state,
135/// );
136/// ```
137///
138/// This example demonstrates how to create a stateful slider and respond to value changes by updating your own state.
139///
140/// # See Also
141/// - [`SliderArgs`]
142/// - [`SliderState`]
143pub fn slider(args: impl Into<SliderArgs>, state: Arc<Mutex<SliderState>>) {
144    let args: SliderArgs = args.into();
145
146    // Background track (inactive part) - capsule shape
147    surface(
148        SurfaceArgsBuilder::default()
149            .width(DimensionValue::Fixed(args.width.to_px()))
150            .height(DimensionValue::Fixed(args.track_height.to_px()))
151            .color(args.inactive_track_color)
152            .shape(Shape::RoundedRectangle {
153                corner_radius: args.track_height.to_px().to_f32() / 2.0,
154                g2_k_value: 2.0, // Capsule shape
155            })
156            .build()
157            .unwrap(),
158        None,
159        move || {
160            // Progress fill (active part) - capsule shape
161            let progress_width = args.width.to_px().to_f32() * args.value;
162            surface(
163                SurfaceArgsBuilder::default()
164                    .width(DimensionValue::Fixed(Px(progress_width as i32)))
165                    .height(DimensionValue::Fill {
166                        min: None,
167                        max: None,
168                    })
169                    .color(args.active_track_color)
170                    .shape(Shape::RoundedRectangle {
171                        corner_radius: args.track_height.to_px().to_f32() / 2.0,
172                        g2_k_value: 2.0, // Capsule shape
173                    })
174                    .build()
175                    .unwrap(),
176                None,
177                || {},
178            );
179        },
180    );
181
182    let on_change = args.on_change.clone();
183    let state_handler_state = state.clone();
184    let disabled = args.disabled;
185
186    state_handler(Box::new(move |input| {
187        if disabled {
188            return;
189        }
190        let mut state = state_handler_state.lock();
191
192        let is_in_component = input.cursor_position_rel.is_some_and(|cursor_pos| {
193            cursor_pos.x.0 >= 0
194                && cursor_pos.x.0 < input.computed_data.width.0
195                && cursor_pos.y.0 >= 0
196                && cursor_pos.y.0 < input.computed_data.height.0
197        });
198
199        // Set cursor to pointer when hovering over the slider
200        if is_in_component {
201            input.requests.cursor_icon = CursorIcon::Pointer;
202        }
203
204        if !is_in_component && !state.is_dragging {
205            return;
206        }
207
208        let mut new_value = None;
209
210        for event in input.cursor_events.iter() {
211            match &event.content {
212                CursorEventContent::Pressed(_) => {
213                    state.focus.request_focus();
214                    state.is_dragging = true;
215
216                    if let Some(pos) = input.cursor_position_rel {
217                        let v =
218                            (pos.x.0 as f32 / input.computed_data.width.0 as f32).clamp(0.0, 1.0);
219                        new_value = Some(v);
220                    }
221                }
222                CursorEventContent::Released(_) => {
223                    state.is_dragging = false;
224                }
225                _ => {}
226            }
227        }
228
229        if state.is_dragging {
230            if let Some(pos) = input.cursor_position_rel {
231                let v = (pos.x.0 as f32 / input.computed_data.width.0 as f32).clamp(0.0, 1.0);
232                new_value = Some(v);
233            }
234        }
235
236        if let Some(v) = new_value {
237            if (v - args.value).abs() > f32::EPSILON {
238                on_change(v);
239            }
240        }
241    }));
242
243    measure(Box::new(move |input| {
244        let self_width = args.width.to_px();
245        let self_height = args.track_height.to_px();
246
247        let track_id = input.children_ids[0];
248
249        // Measure track
250        let track_constraint = Constraint::new(
251            DimensionValue::Fixed(self_width),
252            DimensionValue::Fixed(self_height),
253        );
254        input.measure_child(track_id, &track_constraint)?;
255        input.place_child(track_id, PxPosition::new(Px(0), Px(0)));
256
257        Ok(ComputedData {
258            width: self_width,
259            height: self_height,
260        })
261    }));
262}