Skip to main content

tessera_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 tessera_foundation::gesture::{DragRecognizer, TapRecognizer};
7use tessera_ui::{
8    CallbackWith, Color, ComputedData, Constraint, Dp, FocusProperties, FocusRequester,
9    LayoutResult, MeasurementError, Modifier, PointerInput, PointerInputModifierNode, Px,
10    PxPosition, State,
11    accesskit::Role,
12    layout::{MeasureScope, layout},
13    modifier::{CursorModifierExt as _, FocusModifierExt as _, ModifierCapabilityExt as _},
14    remember, tessera,
15    winit::window::CursorIcon,
16};
17
18use crate::{
19    fluid_glass::{GlassBorder, fluid_glass},
20    glass_progress::glass_progress_fill,
21    modifier::{ModifierExt as _, SemanticsArgs},
22    shape_def::Shape,
23};
24
25const ACCESSIBILITY_STEP: f32 = 0.05;
26
27struct GlassSliderPointerModifierNode {
28    args: GlassSliderConfig,
29    tap_recognizer: State<TapRecognizer>,
30    drag_recognizer: State<DragRecognizer>,
31}
32
33impl PointerInputModifierNode for GlassSliderPointerModifierNode {
34    fn on_pointer_input(&self, mut input: PointerInput<'_>) {
35        if self.args.disabled {
36            return;
37        }
38
39        let controller = self.args.controller;
40        let is_in_component = cursor_within_bounds(input.cursor_position_rel, &input.computed_data);
41
42        if is_in_component || controller.with(|controller| controller.is_dragging()) {
43            let width_f = input.computed_data.width.0 as f32;
44
45            if let Some(value) = process_pointer_gestures(
46                controller,
47                self.tap_recognizer,
48                self.drag_recognizer,
49                &mut input,
50                width_f,
51            ) && (value - self.args.value).abs() > f32::EPSILON
52            {
53                self.args.on_change.call(value);
54            }
55        }
56    }
57}
58
59/// Controller for the `glass_slider` component.
60pub struct GlassSliderController {
61    is_dragging: bool,
62    focus: FocusRequester,
63}
64
65impl GlassSliderController {
66    /// Creates a new slider controller.
67    pub fn new() -> Self {
68        Self {
69            is_dragging: false,
70            focus: FocusRequester::new(),
71        }
72    }
73
74    /// Returns whether the slider thumb is currently being dragged.
75    pub fn is_dragging(&self) -> bool {
76        self.is_dragging
77    }
78
79    /// Sets the dragging state manually. This allows custom gesture handling.
80    pub fn set_dragging(&mut self, dragging: bool) {
81        self.is_dragging = dragging;
82    }
83
84    /// Requests focus for this slider instance.
85    pub fn request_focus(&mut self) {
86        self.focus.request_focus();
87    }
88
89    /// Clears focus from this slider.
90    pub fn clear_focus(&mut self) {
91        self.focus.unfocus();
92    }
93
94    /// Returns `true` if the slider currently has focus.
95    pub fn is_focused(&self) -> bool {
96        self.focus.is_focused()
97    }
98}
99
100impl Default for GlassSliderController {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106#[derive(Clone, PartialEq)]
107struct GlassSliderConfig {
108    value: f32,
109    modifier: Modifier,
110    on_change: CallbackWith<f32>,
111    track_height: Dp,
112    track_tint_color: Color,
113    progress_tint_color: Color,
114    blur_radius: Dp,
115    track_border_width: Dp,
116    disabled: bool,
117    accessibility_label: Option<String>,
118    accessibility_description: Option<String>,
119    controller: State<GlassSliderController>,
120}
121
122fn cursor_within_bounds(cursor_pos: Option<PxPosition>, computed: &ComputedData) -> bool {
123    if let Some(pos) = cursor_pos {
124        let within_x = pos.x >= 0.0 && pos.x < computed.width.0 as f32;
125        let within_y = pos.y >= 0.0 && pos.y < computed.height.0 as f32;
126        within_x && within_y
127    } else {
128        false
129    }
130}
131
132/// Helper: compute normalized progress (0.0..1.0) from cursor X and width.
133/// Returns None when cursor is not available.
134fn cursor_progress(cursor_pos: Option<PxPosition>, width_f: f32) -> Option<f32> {
135    cursor_pos.map(|pos| (pos.x / width_f).clamp(0.0, 1.0))
136}
137
138/// Process pointer gestures and update the slider state accordingly.
139/// Returns the new value (0.0..1.0) if a change should be emitted.
140fn process_pointer_gestures(
141    controller: State<GlassSliderController>,
142    tap_recognizer: State<TapRecognizer>,
143    drag_recognizer: State<DragRecognizer>,
144    input: &mut tessera_ui::PointerInput,
145    width_f: f32,
146) -> Option<f32> {
147    let is_in_component = cursor_within_bounds(input.cursor_position_rel, &input.computed_data);
148    let tap_result = tap_recognizer.with_mut(|recognizer| {
149        recognizer.update(
150            input.pass,
151            input.pointer_changes.as_mut_slice(),
152            is_in_component,
153        )
154    });
155    let drag_result = drag_recognizer.with_mut(|recognizer| {
156        recognizer.update(
157            input.pass,
158            input.pointer_changes.as_mut_slice(),
159            is_in_component,
160        )
161    });
162
163    let mut new_value: Option<f32> = None;
164
165    if tap_result.pressed {
166        controller.with_mut(|c| {
167            c.request_focus();
168        });
169        if let Some(v) = cursor_progress(input.cursor_position_rel, width_f) {
170            new_value = Some(v);
171        }
172    }
173
174    if drag_result.started {
175        controller.with_mut(|c| c.set_dragging(true));
176    }
177
178    if (drag_result.updated || controller.with(|c| c.is_dragging()))
179        && let Some(v) = cursor_progress(input.cursor_position_rel, width_f)
180    {
181        new_value = Some(v);
182    }
183
184    if tap_result.released || drag_result.ended {
185        controller.with_mut(|c| c.set_dragging(false));
186    }
187
188    new_value
189}
190
191/// # glass_slider
192///
193/// Renders an interactive slider with a customizable glass effect.
194///
195/// ## Usage
196///
197/// Allow users to select a value from a continuous range (0.0 to 1.0) by
198/// dragging a thumb.
199///
200/// ## Parameters
201///
202/// - `args` — configures the slider's value, appearance, and `on_change`
203///   callback through the component's builder parameters.
204/// - `controller` — optional controller; use [`glass_slider`] to provide your
205///   own.
206///
207/// ## Examples
208///
209/// ```
210/// use tessera_components::glass_slider::{GlassSliderController, glass_slider};
211/// use tessera_ui::{LayoutResult, remember, tessera};
212///
213/// #[tessera]
214/// fn demo() {
215///     let slider_value = remember(|| 0.5f32);
216///     let slider_controller = remember(GlassSliderController::new);
217///
218///     glass_slider()
219///         .value(slider_value.get())
220///         .on_change(move |new_value| {
221///             slider_value.set(new_value);
222///         })
223///         .controller(slider_controller);
224///
225///     assert_eq!(slider_value.get(), 0.5);
226/// }
227///
228/// # tessera_ui::testing::layout_test(demo).run();
229/// ```
230#[tessera]
231pub fn glass_slider(
232    value: Option<f32>,
233    modifier: Option<Modifier>,
234    on_change: Option<CallbackWith<f32>>,
235    track_height: Option<Dp>,
236    track_tint_color: Option<Color>,
237    progress_tint_color: Option<Color>,
238    blur_radius: Option<Dp>,
239    track_border_width: Option<Dp>,
240    disabled: Option<bool>,
241    #[prop(into)] accessibility_label: Option<String>,
242    #[prop(into)] accessibility_description: Option<String>,
243    controller: Option<State<GlassSliderController>>,
244) {
245    let controller = match controller {
246        Some(controller) => controller,
247        None => remember(GlassSliderController::new),
248    };
249    let slider_args = GlassSliderConfig {
250        value: value.unwrap_or(0.0),
251        modifier: modifier.unwrap_or_else(|| Modifier::new().width(Dp(200.0))),
252        on_change: on_change.unwrap_or_else(CallbackWith::default_value),
253        track_height: track_height.unwrap_or(Dp(12.0)),
254        track_tint_color: track_tint_color.unwrap_or(Color::new(0.3, 0.3, 0.3, 0.15)),
255        progress_tint_color: progress_tint_color.unwrap_or(Color::new(0.5, 0.7, 1.0, 0.25)),
256        blur_radius: blur_radius.unwrap_or(Dp::ZERO),
257        track_border_width: track_border_width.unwrap_or(Dp(1.0)),
258        disabled: disabled.unwrap_or(false),
259        accessibility_label,
260        accessibility_description,
261        controller,
262    };
263    let tap_recognizer = remember(TapRecognizer::default);
264    let drag_recognizer = remember(DragRecognizer::default);
265    render_glass_slider(slider_args, tap_recognizer, drag_recognizer);
266}
267
268#[tessera]
269fn render_glass_slider(
270    args: GlassSliderConfig,
271    tap_recognizer: State<TapRecognizer>,
272    drag_recognizer: State<DragRecognizer>,
273) {
274    let controller = args.controller;
275    let mut modifier = args.modifier.clone();
276    let semantics = SemanticsArgs {
277        role: Some(Role::Slider),
278        label: args.accessibility_label.clone(),
279        description: args.accessibility_description.clone(),
280        numeric_value: Some(args.value as f64),
281        numeric_range: Some((0.0, 1.0)),
282        focusable: !args.disabled,
283        disabled: args.disabled,
284        numeric_value_step: Some(ACCESSIBILITY_STEP as f64),
285        ..Default::default()
286    };
287    modifier = modifier.semantics(semantics);
288    modifier = modifier.then(
289        Modifier::new()
290            .focus_requester(controller.with(|c| c.focus))
291            .focusable()
292            .focus_properties(
293                FocusProperties::new()
294                    .can_focus(!args.disabled)
295                    .can_request_focus(!args.disabled),
296            ),
297    );
298    if !args.disabled {
299        modifier = modifier.hover_cursor_icon(CursorIcon::Pointer);
300    }
301    let modifier = modifier.push_pointer_input(GlassSliderPointerModifierNode {
302        args: args.clone(),
303        tap_recognizer,
304        drag_recognizer,
305    });
306
307    layout()
308        .modifier(modifier)
309        .policy(GlassSliderLayout {
310            track_height: args.track_height.to_px(),
311            fallback_width: Dp(200.0).to_px(),
312        })
313        .child(move || {
314            fluid_glass()
315                .modifier(Modifier::new().fill_max_size())
316                .tint_color(args.track_tint_color)
317                .blur_radius(args.blur_radius)
318                .shape(Shape::CAPSULE)
319                .border(GlassBorder::new(args.track_border_width.into()))
320                .padding(args.track_border_width)
321                .child(move || {
322                    glass_progress_fill()
323                        .value(args.value)
324                        .tint_color(args.progress_tint_color)
325                        .blur_radius(args.blur_radius);
326                });
327        });
328}
329
330#[derive(Clone, Copy, PartialEq)]
331struct GlassSliderLayout {
332    track_height: Px,
333    fallback_width: Px,
334}
335
336impl tessera_ui::LayoutModifierNode for GlassSliderLayout {
337    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
338        let mut result = LayoutResult::default();
339        let self_width = input.parent_constraint().width().clamp(self.fallback_width);
340        let self_height = self.track_height;
341
342        let track = input.children()[0];
343        let track_constraint = Constraint::exact(self_width, self_height);
344        track.measure(input, &track_constraint)?;
345        result.place_child(track, PxPosition::new(Px(0), Px(0)));
346
347        Ok(result.with_size(ComputedData {
348            width: self_width,
349            height: self_height,
350        }))
351    }
352}