Skip to main content

tessera_components/slider/
indicator.rs

1//! Floating value labels for slider handles.
2//!
3//! ## Usage
4//!
5//! Help users select exact values while adjusting settings.
6
7use tessera_ui::{
8    AxisConstraint, Color, ComputedData, Constraint, FrameNanosControl, LayoutResult,
9    MeasurementError, Modifier, Px, PxPosition, RenderSlotWith, State, current_frame_nanos, key,
10    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
11    modifier::FocusModifierExt as _,
12    receive_frame_nanos, remember, remember_with_key, tessera, use_context,
13};
14
15use crate::{
16    alignment::Alignment,
17    boxed::boxed,
18    modifier::{ModifierExt as _, Padding, SemanticsArgs},
19    progress::cubic_bezier_easing,
20    shape_def::Shape,
21    surface::surface,
22    text::text,
23    theme::{MaterialTheme, TextStyle},
24};
25
26use super::SliderOrientation;
27
28/// When a slider's floating value label is visible.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum SliderValueIndicatorVisibility {
31    /// Keep the label hidden.
32    #[default]
33    Hidden,
34    /// Show the label while its handle is focused or being dragged.
35    OnInteraction,
36    /// Show the label whenever its enabled handle is on screen.
37    Always,
38}
39
40impl SliderValueIndicatorVisibility {
41    pub(super) fn visible(self, interacting: bool, disabled: bool) -> bool {
42        !disabled
43            && match self {
44                Self::Hidden => false,
45                Self::OnInteraction => interacting,
46                Self::Always => true,
47            }
48    }
49}
50
51#[derive(Clone, PartialEq)]
52struct Request {
53    anchor: State<tessera_ui::LayoutCoordinates>,
54    label: String,
55    visible: bool,
56    interacting: bool,
57    orientation: SliderOrientation,
58    background: Color,
59    foreground: Color,
60    style: TextStyle,
61}
62
63/// The overlay registration target supplied to slider content.
64#[derive(Clone, Copy, PartialEq, Eq, Hash)]
65pub struct SliderValueIndicatorHost {
66    entries: State<Vec<State<Request>>>,
67}
68
69struct Registration {
70    entries: State<Vec<State<Request>>>,
71    request: State<Request>,
72}
73
74impl Drop for Registration {
75    fn drop(&mut self) {
76        if self.entries.is_alive() {
77            self.entries
78                .with_mut(|entries| entries.retain(|entry| *entry != self.request));
79        }
80    }
81}
82
83#[derive(Clone, Copy)]
84struct Reveal {
85    value: f32,
86    origin: f32,
87    target: bool,
88    start: u64,
89}
90
91fn emphasized(progress: f32) -> f32 {
92    if progress <= 1.0 / 6.0 {
93        cubic_bezier_easing(progress * 6.0, 0.3, 0.0, 0.8, 0.15) * 0.4
94    } else {
95        let t = (progress - 1.0 / 6.0) * 1.2;
96        0.4 + cubic_bezier_easing(t, 0.05, 0.7, 0.1, 1.0) * 0.6
97    }
98}
99
100impl Reveal {
101    fn new() -> Self {
102        Self {
103            value: 0.0,
104            origin: 0.0,
105            target: false,
106            start: 0,
107        }
108    }
109
110    fn running(&self) -> bool {
111        self.value != f32::from(u8::from(self.target))
112    }
113
114    fn advance(&mut self, now: u64) {
115        let duration = if self.target {
116            400_000_000
117        } else {
118            150_000_000
119        };
120        let t = (now.saturating_sub(self.start) as f64 / duration as f64).clamp(0.0, 1.0) as f32;
121        let target = f32::from(u8::from(self.target));
122        if t >= 1.0 {
123            self.value = target;
124        } else {
125            let t = if self.target {
126                emphasized(t)
127            } else {
128                cubic_bezier_easing(t, 0.3, 0.0, 0.8, 0.15)
129            };
130            self.value = self.origin + (target - self.origin) * t;
131        }
132    }
133
134    fn retarget(&mut self, target: bool, now: u64) {
135        if self.target == target {
136            return;
137        }
138        self.advance(now);
139        self.origin = self.value;
140        self.target = target;
141        self.start = now;
142    }
143}
144
145#[derive(Clone, PartialEq)]
146struct HostLayout;
147
148impl tessera_ui::LayoutModifierNode for HostLayout {
149    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
150        let children = input.children();
151        let parent = input.parent_constraint();
152        let constraint = Constraint::new(parent.width(), parent.height());
153        let main = children[0].measure(input, &constraint)?;
154        let mut result = LayoutResult::new(main.size());
155        result.place_child(children[0], PxPosition::ZERO);
156        let constraint = Constraint::new(
157            AxisConstraint::exact(main.width),
158            AxisConstraint::exact(main.height),
159        );
160        for child in &children[1..] {
161            child.measure(input, &constraint)?;
162            result.place_child(*child, PxPosition::ZERO);
163        }
164        Ok(result)
165    }
166}
167
168#[derive(Clone)]
169struct LabelLayout {
170    anchor: State<tessera_ui::LayoutCoordinates>,
171    origin: State<tessera_ui::LayoutCoordinates>,
172    orientation: SliderOrientation,
173    scale: f32,
174}
175
176impl PartialEq for LabelLayout {
177    fn eq(&self, other: &Self) -> bool {
178        self.anchor == other.anchor
179            && self.origin == other.origin
180            && self.orientation == other.orientation
181            && self.scale == other.scale
182    }
183}
184
185fn position(
186    anchor: tessera_ui::FloatRect,
187    popup: ComputedData,
188    viewport: ComputedData,
189    orientation: SliderOrientation,
190    scale: f32,
191) -> PxPosition {
192    let gap = tessera_ui::Dp(4.0).to_pixels_f32();
193    let margin = tessera_ui::Dp(8.0).to_pixels_f32();
194    let (w, h) = (popup.width.to_f32(), popup.height.to_f32());
195    let (width, height) = (viewport.width.to_f32(), viewport.height.to_f32());
196    let (left, top) = (anchor.x, anchor.y);
197    let (right, bottom) = (anchor.x + anchor.width, anchor.y + anchor.height);
198    let (mut x, mut y) = match orientation {
199        SliderOrientation::Horizontal => {
200            let above = top - gap - h;
201            (
202                (left + right - w) * 0.5,
203                if above >= margin { above } else { bottom + gap },
204            )
205        }
206        SliderOrientation::Vertical => {
207            let before = left - gap - w;
208            (
209                if before >= margin {
210                    before
211                } else {
212                    right + gap
213                },
214                (top + bottom - h) * 0.5,
215            )
216        }
217    };
218    let travel = (1.0 - scale) * tessera_ui::Dp(8.8).to_pixels_f32();
219    match orientation {
220        SliderOrientation::Horizontal => y += if y < top { travel } else { -travel },
221        SliderOrientation::Vertical => x += if x < left { travel } else { -travel },
222    }
223    PxPosition::from_f32_xy(
224        x.clamp(
225            margin.min(width),
226            (width - w - margin).max(margin.min(width)),
227        ),
228        y.clamp(
229            margin.min(height),
230            (height - h - margin).max(margin.min(height)),
231        ),
232    )
233}
234
235impl LabelLayout {
236    fn popup_position(&self, popup: ComputedData, size: ComputedData) -> Option<PxPosition> {
237        if !self.anchor.is_alive() {
238            return None;
239        }
240        let rect = self.anchor.get();
241        let origin = self.origin.get();
242        let anchor = origin.local_to_window.inverse_rect(rect.window_bounds());
243        if anchor.width <= 0.0
244            || anchor.height <= 0.0
245            || anchor.x + anchor.width <= 0.0
246            || anchor.y + anchor.height <= 0.0
247            || anchor.x >= size.width.to_f32()
248            || anchor.y >= size.height.to_f32()
249        {
250            return None;
251        }
252        Some(position(anchor, popup, size, self.orientation, self.scale))
253    }
254}
255
256impl tessera_ui::LayoutModifierNode for LabelLayout {
257    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
258        let parent = input.parent_constraint();
259        let size = ComputedData {
260            width: parent.width().clamp(Px::ZERO),
261            height: parent.height().clamp(Px::ZERO),
262        };
263        let mut result = LayoutResult::new(size);
264        let Some(child) = input.children().first().copied() else {
265            return Ok(result);
266        };
267        let margin = tessera_ui::Dp(16.0).to_px();
268        let constraint = Constraint::new(
269            AxisConstraint::at_most((size.width - margin).max(Px::ZERO)),
270            AxisConstraint::at_most((size.height - margin).max(Px::ZERO)),
271        );
272        let popup = child.measure(input, &constraint)?;
273        if let Some(position) = input.placement(|| self.popup_position(popup.size(), size)) {
274            result.place_child(child, position);
275        }
276        Ok(result)
277    }
278
279    fn place_children(
280        &self,
281        scope: &PlacementScope<'_>,
282        _inner: &PlacementResult,
283    ) -> Option<PlacementResult> {
284        let mut result = LayoutResult::new(scope.size());
285        if let Some(child) = scope.children().first().copied()
286            && let Some(position) = self.popup_position(child.size(), scope.size())
287        {
288            result.place_child(child, position);
289        }
290        Some(result.into_placement_result())
291    }
292}
293
294#[tessera]
295fn label_layer(
296    request: Option<State<Request>>,
297    origin: Option<State<tessera_ui::LayoutCoordinates>>,
298) {
299    let request_state = request.expect("slider label request");
300    let request = request_state.get();
301    let origin = origin.expect("slider label origin");
302    let now = current_frame_nanos();
303    let reveal = remember(Reveal::new);
304    if reveal.with(|reveal| reveal.target != request.visible) {
305        reveal.with_mut(|reveal| reveal.retarget(request.visible, now));
306    }
307    if reveal.with(Reveal::running) {
308        receive_frame_nanos(move |now| {
309            reveal.with_mut(|reveal| reveal.advance(now));
310            if reveal.with(Reveal::running) {
311                FrameNanosControl::Continue
312            } else {
313                FrameNanosControl::Stop
314            }
315        });
316    }
317    let scale = reveal.with(|reveal| reveal.value);
318    let next = (request.clone(), scale);
319    let data = remember(|| next.clone());
320    if data.with(|current| current != &next) {
321        data.set(next);
322    }
323    layout()
324        .modifier(Modifier::new().semantics(SemanticsArgs {
325            hidden: true,
326            ..Default::default()
327        }))
328        .policy(LabelLayout {
329            anchor: request.anchor,
330            origin,
331            orientation: request.orientation,
332            scale,
333        })
334        .child(move || {
335            let (request, scale) = data.get();
336            if scale > 0.0 {
337                let dp = move |value| tessera_ui::Dp(value * f64::from(scale));
338                let modifier = Modifier::new().alpha(scale).constrain(
339                    Some(AxisConstraint::new(dp(48.0).to_px(), None)),
340                    Some(AxisConstraint::new(dp(44.0).to_px(), None)),
341                );
342                #[cfg(test)]
343                let modifier = modifier.semantics(SemanticsArgs {
344                    test_tag: Some("slider-value-label".into()),
345                    label: Some(request.label.clone()),
346                    ..Default::default()
347                });
348                surface()
349                    .modifier(modifier)
350                    .shape(Shape::CAPSULE)
351                    .style(request.background.into())
352                    .child(move || {
353                        let scale = data.with(|(_, scale)| *scale);
354                        let dp = move |value| tessera_ui::Dp(value * f64::from(scale));
355                        boxed()
356                            .modifier(
357                                Modifier::new().padding(Padding::symmetric(dp(16.0), dp(12.0))),
358                            )
359                            .alignment(Alignment::Center)
360                            .children(move || {
361                                let (request, scale) = data.get();
362                                let mut style = request.style;
363                                style.font_size =
364                                    tessera_ui::Dp(style.font_size.0 * f64::from(scale));
365                                style.line_height = style
366                                    .line_height
367                                    .map(|height| tessera_ui::Dp(height.0 * f64::from(scale)));
368                                style.letter_spacing =
369                                    tessera_ui::Dp(style.letter_spacing.0 * f64::from(scale));
370                                text(request.label).color(request.foreground).style(style);
371                            });
372                    });
373            }
374        });
375}
376
377/// # slider_value_indicator_provider
378///
379/// Draw slider value labels above page content and scrolling containers.
380///
381/// ## Usage
382///
383/// Wrap a page that contains sliders with optional floating value indicators.
384///
385/// ## Parameters
386///
387/// - `modifier` — page placement and bounds; fills the available size by
388///   default.
389/// - `child` — page content receiving the explicit host for its slider labels.
390///
391/// ## Examples
392///
393/// ```
394/// use tessera_components::{
395///     slider::{SliderValueIndicatorVisibility, slider, slider_value_indicator_provider},
396///     theme::material_theme,
397/// };
398/// use tessera_ui::{remember, tessera};
399/// #[tessera]
400/// fn content() {
401///     let value = remember(|| 0.5_f32);
402///     material_theme().child(move || {
403///         slider_value_indicator_provider().child(move |host| {
404///             slider().value_indicator_host(host)
405///                 .value(value.get())
406///                 .on_change(move |next| value.set(next))
407///                 .value_indicator(SliderValueIndicatorVisibility::OnInteraction);
408///         });
409///     });
410///     assert_eq!(value.get(), 0.5);
411/// }
412/// # tessera_ui::testing::layout_test(content).run();
413/// ```
414#[tessera]
415pub fn slider_value_indicator_provider(
416    modifier: Option<Modifier>,
417    child: Option<RenderSlotWith<SliderValueIndicatorHost>>,
418) {
419    let entries = remember(Vec::<State<Request>>::new);
420    let origin = remember(tessera_ui::LayoutCoordinates::default);
421    let next = (
422        modifier.unwrap_or_else(|| Modifier::new().fill_max_size()),
423        child,
424    );
425    let child_data = remember(|| next.clone());
426    if child_data.get() != next {
427        child_data.set(next);
428    }
429    layout()
430        .modifier(
431            child_data
432                .with(|(modifier, _)| modifier.clone())
433                .on_layout_coordinates(move |coordinates| {
434                    origin.set_if_changed(coordinates);
435                }),
436        )
437        .policy(HostLayout)
438        .child(move || {
439            layout().child(move || {
440                if let Some(child) = child_data.with(|(_, child)| *child) {
441                    child.render(SliderValueIndicatorHost { entries });
442                }
443            });
444            let mut requests: Vec<_> = entries
445                .get()
446                .into_iter()
447                .filter(|request| request.is_alive())
448                .collect();
449            requests.sort_by_key(|request| request.with(|request| request.interacting));
450            for request in requests {
451                if request.is_alive() {
452                    key(request, move || {
453                        label_layer().request(request).origin(origin);
454                    });
455                }
456            }
457        });
458}
459
460#[tessera]
461pub(super) fn register_indicator(
462    host: Option<SliderValueIndicatorHost>,
463    anchor: Option<State<tessera_ui::LayoutCoordinates>>,
464    label: Option<String>,
465    visible: Option<bool>,
466    interacting: Option<bool>,
467    orientation: Option<SliderOrientation>,
468) {
469    let host = host.expect("visible slider indicators require a provider host");
470    let theme = use_context::<MaterialTheme>()
471        .map(|theme| theme.get())
472        .unwrap_or_default();
473    let next = Request {
474        anchor: anchor.expect("slider label anchor"),
475        label: label.unwrap_or_default(),
476        visible: visible.unwrap_or(false),
477        interacting: interacting.unwrap_or(false),
478        orientation: orientation.unwrap_or_default(),
479        background: theme.color_scheme.inverse_surface,
480        foreground: theme.color_scheme.inverse_on_surface,
481        style: theme.typography.label_large,
482    };
483    let request = remember(|| next.clone());
484    if request.get() != next {
485        request.set(next);
486    }
487    let _registration = remember_with_key(host, || Registration {
488        entries: host.entries,
489        request,
490    });
491    if !host.entries.with(|entries| entries.contains(&request)) {
492        host.entries.with_mut(|entries| entries.push(request));
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::Reveal;
499
500    #[test]
501    fn value_label_reveal_reverses_without_a_jump_and_settles_at_material_durations() {
502        let mut reveal = Reveal::new();
503        reveal.retarget(true, 1_000_000_000);
504        reveal.advance(1_035_833_333);
505        assert!((reveal.value - 0.0725).abs() < 0.00004);
506        let halfway = reveal.value;
507        reveal.retarget(false, 1_035_833_333);
508        assert_eq!(reveal.value, halfway);
509        reveal.advance(1_185_833_333);
510        assert_eq!(reveal.value, 0.0);
511        assert!(!reveal.running());
512        reveal.retarget(true, 2_000_000_000);
513        reveal.advance(2_399_000_000);
514        assert!(reveal.running());
515        reveal.advance(2_400_000_000);
516        assert_eq!(reveal.value, 1.0);
517        assert!(!reveal.running());
518    }
519}