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