1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum SliderValueIndicatorVisibility {
31 #[default]
33 Hidden,
34 OnInteraction,
36 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#[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#[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}