tessera_ui_basic_components/
glass_slider.rs

1//! A slider component with a glassmorphic visual style.
2//!
3//! ## Usage
4//!
5//! Use 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, Px, PxPosition,
12    accesskit::{Action, Role},
13    focus_state::Focus,
14    tessera,
15    winit::window::CursorIcon,
16};
17
18use crate::{
19    fluid_glass::{FluidGlassArgsBuilder, GlassBorder, fluid_glass},
20    shape_def::Shape,
21};
22
23const ACCESSIBILITY_STEP: f32 = 0.05;
24
25/// State for the `glass_slider` component.
26pub(crate) struct GlassSliderStateInner {
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 GlassSliderStateInner {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl GlassSliderStateInner {
40    pub fn new() -> Self {
41        Self {
42            is_dragging: false,
43            focus: Focus::new(),
44        }
45    }
46}
47
48/// External state handle for the `glass_slider` component.
49///
50/// # Example
51///
52/// ```
53/// use tessera_ui_basic_components::glass_slider::GlassSliderState;
54///
55/// let slider_state = GlassSliderState::new();
56/// ```
57#[derive(Clone)]
58pub struct GlassSliderState {
59    inner: Arc<RwLock<GlassSliderStateInner>>,
60}
61
62impl GlassSliderState {
63    /// Creates a new slider state handle.
64    pub fn new() -> Self {
65        Self {
66            inner: Arc::new(RwLock::new(GlassSliderStateInner::new())),
67        }
68    }
69
70    pub(crate) fn read(&self) -> RwLockReadGuard<'_, GlassSliderStateInner> {
71        self.inner.read()
72    }
73
74    pub(crate) fn write(&self) -> RwLockWriteGuard<'_, GlassSliderStateInner> {
75        self.inner.write()
76    }
77
78    /// Returns whether the slider thumb is currently being dragged.
79    pub fn is_dragging(&self) -> bool {
80        self.inner.read().is_dragging
81    }
82
83    /// Sets the dragging state manually. This allows custom gesture handling.
84    pub fn set_dragging(&self, dragging: bool) {
85        self.inner.write().is_dragging = dragging;
86    }
87
88    /// Requests focus for this slider instance.
89    pub fn request_focus(&self) {
90        self.inner.write().focus.request_focus();
91    }
92
93    /// Clears focus from this slider.
94    pub fn clear_focus(&self) {
95        self.inner.write().focus.unfocus();
96    }
97
98    /// Returns `true` if the slider currently has focus.
99    pub fn is_focused(&self) -> bool {
100        self.inner.read().focus.is_focused()
101    }
102}
103
104impl Default for GlassSliderState {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110/// Arguments for the `glass_slider` component.
111#[derive(Builder, Clone)]
112#[builder(pattern = "owned")]
113pub struct GlassSliderArgs {
114    /// The current value of the slider, ranging from 0.0 to 1.0.
115    #[builder(default = "0.0")]
116    pub value: f32,
117
118    /// Callback function triggered when the slider's value changes.
119    #[builder(default = "Arc::new(|_| {})")]
120    pub on_change: Arc<dyn Fn(f32) + Send + Sync>,
121
122    /// The width of the slider track.
123    #[builder(default = "Dp(200.0)")]
124    pub width: Dp,
125
126    /// The height of the slider track.
127    #[builder(default = "Dp(12.0)")]
128    pub track_height: Dp,
129
130    /// Glass tint color for the track background.
131    #[builder(default = "Color::new(0.3, 0.3, 0.3, 0.15)")]
132    pub track_tint_color: Color,
133
134    /// Glass tint color for the progress fill.
135    #[builder(default = "Color::new(0.5, 0.7, 1.0, 0.25)")]
136    pub progress_tint_color: Color,
137
138    /// Glass blur radius for all components.
139    #[builder(default = "Dp(0.0)")]
140    pub blur_radius: Dp,
141
142    /// Border width for the track.
143    #[builder(default = "Dp(1.0)")]
144    pub track_border_width: Dp,
145
146    /// Disable interaction.
147    #[builder(default = "false")]
148    pub disabled: bool,
149    /// Optional accessibility label read by assistive technologies.
150    #[builder(default, setter(strip_option, into))]
151    pub accessibility_label: Option<String>,
152    /// Optional accessibility description.
153    #[builder(default, setter(strip_option, into))]
154    pub accessibility_description: Option<String>,
155}
156
157/// Helper: check if a cursor position is inside a measured component area.
158/// Extracted to reduce duplication and keep the input handler concise.
159fn cursor_within_component(cursor_pos: Option<PxPosition>, computed: &ComputedData) -> bool {
160    if let Some(pos) = cursor_pos {
161        let within_x = pos.x.0 >= 0 && pos.x.0 < computed.width.0;
162        let within_y = pos.y.0 >= 0 && pos.y.0 < computed.height.0;
163        within_x && within_y
164    } else {
165        false
166    }
167}
168
169/// Helper: compute normalized progress (0.0..1.0) from cursor X and width.
170/// Returns None when cursor is not available.
171fn cursor_progress(cursor_pos: Option<PxPosition>, width_f: f32) -> Option<f32> {
172    cursor_pos.map(|pos| (pos.x.0 as f32 / width_f).clamp(0.0, 1.0))
173}
174
175/// Helper: compute progress fill width in Px, clamped to >= 0.
176fn compute_progress_width(total_width: Px, value: f32, border_padding_px: f32) -> Px {
177    let total_f = total_width.0 as f32;
178    let mut w = total_f * value - border_padding_px;
179    if w < 0.0 {
180        w = 0.0;
181    }
182    Px(w as i32)
183}
184
185/// Process cursor events and update the slider state accordingly.
186/// Returns the new value (0.0..1.0) if a change should be emitted.
187fn process_cursor_events(
188    state: &GlassSliderState,
189    input: &tessera_ui::InputHandlerInput,
190    width_f: f32,
191) -> Option<f32> {
192    let mut new_value: Option<f32> = None;
193
194    for event in input.cursor_events.iter() {
195        match &event.content {
196            CursorEventContent::Pressed(_) => {
197                {
198                    let mut inner = state.write();
199                    inner.focus.request_focus();
200                    inner.is_dragging = true;
201                }
202                if let Some(v) = cursor_progress(input.cursor_position_rel, width_f) {
203                    new_value = Some(v);
204                }
205            }
206            CursorEventContent::Released(_) => {
207                state.write().is_dragging = false;
208            }
209            _ => {}
210        }
211    }
212
213    if state.read().is_dragging
214        && let Some(v) = cursor_progress(input.cursor_position_rel, width_f)
215    {
216        new_value = Some(v);
217    }
218
219    new_value
220}
221
222/// # glass_slider
223///
224/// Renders an interactive slider with a customizable glass effect.
225///
226/// ## Usage
227///
228/// Allow users to select a value from a continuous range (0.0 to 1.0) by dragging a thumb.
229///
230/// ## Parameters
231///
232/// - `args` — configures the slider's value, appearance, and `on_change` callback; see [`GlassSliderArgs`].
233/// - `state` — a clonable [`GlassSliderState`] to manage interaction state like dragging and focus.
234///
235/// ## Examples
236///
237/// ```
238/// use std::sync::{Arc, Mutex};
239/// use tessera_ui_basic_components::glass_slider::{
240///     glass_slider, GlassSliderArgsBuilder, GlassSliderState,
241/// };
242///
243/// // In a real app, this would be part of your application's state.
244/// let slider_value = Arc::new(Mutex::new(0.5));
245/// let slider_state = GlassSliderState::new();
246///
247/// let on_change = {
248///     let slider_value = slider_value.clone();
249///     Arc::new(move |new_value| {
250///         *slider_value.lock().unwrap() = new_value;
251///     })
252/// };
253///
254/// let args = GlassSliderArgsBuilder::default()
255///     .value(*slider_value.lock().unwrap())
256///     .on_change(on_change)
257///     .build()
258///     .unwrap();
259///
260/// // The component would be called in the UI like this:
261/// // glass_slider(args, slider_state);
262///
263/// // For the doctest, we can simulate the callback.
264/// (args.on_change)(0.75);
265/// assert_eq!(*slider_value.lock().unwrap(), 0.75);
266/// ```
267#[tessera]
268pub fn glass_slider(args: impl Into<GlassSliderArgs>, state: GlassSliderState) {
269    let args: GlassSliderArgs = args.into();
270    let border_padding_px = args.track_border_width.to_px().to_f32() * 2.0;
271
272    // External track (background) with border - capsule shape
273    fluid_glass(
274        FluidGlassArgsBuilder::default()
275            .width(DimensionValue::Fixed(args.width.to_px()))
276            .height(DimensionValue::Fixed(args.track_height.to_px()))
277            .tint_color(args.track_tint_color)
278            .blur_radius(args.blur_radius)
279            .shape(Shape::capsule())
280            .border(GlassBorder::new(args.track_border_width.into()))
281            .padding(args.track_border_width)
282            .build()
283            .expect("builder construction failed"),
284        None,
285        move || {
286            // Internal progress fill - capsule shape using surface
287            let progress_width_px =
288                compute_progress_width(args.width.to_px(), args.value, border_padding_px);
289            fluid_glass(
290                FluidGlassArgsBuilder::default()
291                    .width(DimensionValue::Fixed(progress_width_px))
292                    .height(DimensionValue::Fill {
293                        min: None,
294                        max: None,
295                    })
296                    .tint_color(args.progress_tint_color)
297                    .shape(Shape::capsule())
298                    .refraction_amount(0.0)
299                    .build()
300                    .expect("builder construction failed"),
301                None,
302                || {},
303            );
304        },
305    );
306
307    let on_change = args.on_change.clone();
308    let args_for_handler = args.clone();
309    let state_for_handler = state.clone();
310    input_handler(Box::new(move |mut input| {
311        if !args_for_handler.disabled {
312            let is_in_component =
313                cursor_within_component(input.cursor_position_rel, &input.computed_data);
314
315            if is_in_component {
316                input.requests.cursor_icon = CursorIcon::Pointer;
317            }
318
319            if is_in_component || state_for_handler.read().is_dragging {
320                let width_f = input.computed_data.width.0 as f32;
321
322                if let Some(v) = process_cursor_events(&state_for_handler, &input, width_f)
323                    && (v - args_for_handler.value).abs() > f32::EPSILON
324                {
325                    on_change(v);
326                }
327            }
328        }
329
330        apply_glass_slider_accessibility(
331            &mut input,
332            &args_for_handler,
333            args_for_handler.value,
334            &args_for_handler.on_change,
335        );
336    }));
337
338    measure(Box::new(move |input| {
339        let self_width = args.width.to_px();
340        let self_height = args.track_height.to_px();
341
342        let track_id = input.children_ids[0];
343
344        // Measure track
345        let track_constraint = Constraint::new(
346            DimensionValue::Fixed(self_width),
347            DimensionValue::Fixed(self_height),
348        );
349        input.measure_child(track_id, &track_constraint)?;
350        input.place_child(track_id, PxPosition::new(Px(0), Px(0)));
351
352        Ok(ComputedData {
353            width: self_width,
354            height: self_height,
355        })
356    }));
357}
358
359fn apply_glass_slider_accessibility(
360    input: &mut tessera_ui::InputHandlerInput<'_>,
361    args: &GlassSliderArgs,
362    current_value: f32,
363    on_change: &Arc<dyn Fn(f32) + Send + Sync>,
364) {
365    let mut builder = input.accessibility().role(Role::Slider);
366
367    if let Some(label) = args.accessibility_label.as_ref() {
368        builder = builder.label(label.clone());
369    }
370    if let Some(description) = args.accessibility_description.as_ref() {
371        builder = builder.description(description.clone());
372    }
373
374    builder = builder
375        .numeric_value(current_value as f64)
376        .numeric_range(0.0, 1.0);
377
378    if args.disabled {
379        builder = builder.disabled();
380    } else {
381        builder = builder
382            .action(Action::Increment)
383            .action(Action::Decrement)
384            .focusable();
385    }
386
387    builder.commit();
388
389    if args.disabled {
390        return;
391    }
392
393    let value_for_handler = current_value;
394    let on_change = on_change.clone();
395    input.set_accessibility_action_handler(move |action| {
396        let new_value = match action {
397            Action::Increment => Some((value_for_handler + ACCESSIBILITY_STEP).clamp(0.0, 1.0)),
398            Action::Decrement => Some((value_for_handler - ACCESSIBILITY_STEP).clamp(0.0, 1.0)),
399            _ => None,
400        };
401
402        if let Some(new_value) = new_value
403            && (new_value - value_for_handler).abs() > f32::EPSILON
404        {
405            on_change(new_value);
406        }
407    });
408}