Skip to main content

frus_widgets/
ink.rs

1//! The **ink ripple**: the touch feedback of a material surface.
2//!
3//! A circle grows from where the finger landed, drifts towards the middle of the box
4//! and fades out. Everything about its motion is the reference's, transcribed rather
5//! than approximated: the five durations, the starting radius of 30 %, the five extra
6//! pixels the final radius overshoots by, the `ease` curve driving both radius and
7//! drift, and the fade-out that only *begins* three fifths of the way through its own
8//! timeline.
9//!
10//! Two pieces are needed to see one. A widget declares that it takes ink, through
11//! [`Widget::ink`](crate::Widget::ink) — the shape to clip to and the colour to splash
12//! in. The runtime keeps the ripples themselves ([`Runtime::ink`](crate::Runtime::ink)),
13//! since a ripple outlives the frame it was born in: it is the shell that knows a
14//! finger went down, and the paint walk that knows where the box is.
15//!
16//! [`InkWell`] is the ready-made wrapper — a transparent box that splashes and clicks,
17//! like the reference's widget of the same name.
18
19use frus_core::{
20    BorderRadius, ClipShape, Color, Curve, LayerFilter, Path, Point, Primitive, Rect, Scene, Size,
21};
22use frus_layout::Style;
23
24use crate::interaction::Status;
25use crate::theme::Theme;
26use crate::widget::Widget;
27
28/// How long the ink takes to reach full opacity.
29const FADE_IN: f32 = 0.075;
30/// How long the radius takes to travel while the finger is **still down** — the slow
31/// swell of a press being held.
32const RADIUS_HELD: f32 = 1.0;
33/// How long the radius has left once the tap is confirmed: the swell speeds up.
34const RADIUS_CONFIRMED: f32 = 0.225;
35/// How long the fade-out timeline runs for a confirmed tap.
36const FADE_OUT: f32 = 0.375;
37/// How long it runs for a cancelled one — a finger that slid off.
38const CANCEL: f32 = 0.075;
39/// The fade-out does nothing for the first 225 ms of its 375 ms: the ink holds its
40/// colour while the circle finishes growing, and only then disappears.
41const FADE_OUT_START: f32 = 225.0 / 375.0;
42/// The radius starts at 30 % of its target (a diameter of 60 %).
43const START_FRACTION: f32 = 0.30;
44/// And ends 5 px past it, so the ink clears the corners of the box it is clipped to.
45const OVERSHOOT: f32 = 5.0;
46
47/// The default splash opacity — the state-layer weight the reference gives a pressed
48/// surface. The colour itself comes from the theme, or from the caller.
49const DEFAULT_ALPHA: f32 = 0.12;
50
51/// The splash colour a surface gets when it has not been told otherwise: the theme's
52/// `on_surface`, at the weight the reference gives a pressed state layer.
53///
54/// It is a **default**, not a rule — every widget that takes ink can hand back a colour
55/// of its own, and every one that builds on [`InkWell`] can be given one by the caller.
56pub fn default_splash(theme: &Theme) -> Color {
57    theme
58        .widgets
59        .ink
60        .color
61        .unwrap_or_else(|| theme.scheme.on_surface.fade(DEFAULT_ALPHA))
62}
63
64/// What a widget declares when it takes ink: what colour to splash in, and the shape to
65/// keep the splash inside.
66///
67/// A widget returns this from [`Widget::ink`](crate::Widget::ink) — with the theme in
68/// hand, so a coloured surface can splash in its own `on` colour instead of the one a
69/// plain surface would use. The walk then paints the runtime's ripples directly over
70/// that widget's own paint and under its children, which is where a material surface
71/// puts them.
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct InkStyle {
74    /// The splash colour, **alpha included**: how present the ink is, not just its hue.
75    pub color: Color,
76    /// The corner radii the ink is clipped to. Ink never escapes its box; this says how
77    /// that box is shaped.
78    pub radius: BorderRadius,
79}
80
81impl InkStyle {
82    /// The ink a plain surface takes: the theme's splash, square corners.
83    pub fn of(theme: &Theme) -> Self {
84        Self {
85            color: default_splash(theme),
86            radius: BorderRadius::ZERO,
87        }
88    }
89
90    /// Splashes in a colour of its own, **alpha included**.
91    pub fn color(mut self, color: Color) -> Self {
92        self.color = color;
93        self
94    }
95
96    /// Clips the ink to this rounding — whatever draws the surface underneath.
97    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
98        self.radius = radius.into();
99        self
100    }
101}
102
103/// One splash: a single tap's worth of ink.
104///
105/// Its three timelines run independently, exactly as the reference's three animation
106/// controllers do — which is why the alpha can still be rising while the radius is
107/// already most of the way out.
108#[derive(Clone, Copy, Debug)]
109struct Ripple {
110    /// Where the finger landed, in the box's own coordinates.
111    origin: Point,
112    /// The middle of the box — where the circle drifts to.
113    centre: Point,
114    /// The radius it grows towards.
115    target: f32,
116    /// Fade-in progress, `0..=1`.
117    fade_in: f32,
118    /// Is the fade-in still running? A cancel **stops** it where it stands, and the
119    /// alpha switches over to the fade-out from that moment.
120    fade_in_running: bool,
121    /// Radius progress, `0..=1`, before the curve.
122    radius_t: f32,
123    /// How long the whole radius timeline lasts. Starts at [`RADIUS_HELD`] and drops
124    /// to [`RADIUS_CONFIRMED`] when the tap is confirmed, which is how a release
125    /// makes the ink hurry.
126    radius_seconds: f32,
127    /// Fade-out progress, `0..=1`.
128    fade_out: f32,
129    /// Its rate, per second. Zero while the finger is down: the ink waits.
130    fade_out_rate: f32,
131}
132
133impl Ripple {
134    fn new(origin: Point, size: Size) -> Self {
135        Self {
136            origin,
137            centre: Point::new(size.width / 2.0, size.height / 2.0),
138            target: target_radius(size),
139            fade_in: 0.0,
140            fade_in_running: true,
141            radius_t: 0.0,
142            radius_seconds: RADIUS_HELD,
143            fade_out: 0.0,
144            fade_out_rate: 0.0,
145        }
146    }
147
148    /// Is the finger still down on this one?
149    fn held(&self) -> bool {
150        self.fade_out_rate == 0.0
151    }
152
153    /// The tap completed: the radius finishes over [`RADIUS_CONFIRMED`] and the ink
154    /// begins its long fade.
155    fn confirm(&mut self) {
156        if !self.held() {
157            return;
158        }
159        self.radius_seconds = RADIUS_CONFIRMED;
160        self.fade_out_rate = 1.0 / FADE_OUT;
161    }
162
163    /// The tap did not complete — the finger slid off, or the widget went away. The
164    /// fade-in stops where it is and the ink leaves quickly.
165    ///
166    /// The seeding of `fade_out` is the reference's, and it has a visible consequence
167    /// worth knowing: a ripple cancelled *mid* fade-in briefly jumps to full opacity,
168    /// because the fade-out timeline it is dropped into does nothing before
169    /// [`FADE_OUT_START`]. Over 75 ms it reads as a flick rather than a flash.
170    fn cancel(&mut self) {
171        if !self.held() {
172            return;
173        }
174        self.fade_in_running = false;
175        self.fade_out = 1.0 - self.fade_in;
176        let left = 1.0 - self.fade_out;
177        self.fade_out_rate = if left > 0.0 { left / CANCEL } else { f32::MAX };
178    }
179
180    /// Advances every timeline by `dt`. Returns `true` while there is still something
181    /// to show.
182    fn advance(&mut self, dt: f32) -> bool {
183        if self.fade_in_running && self.fade_in < 1.0 {
184            self.fade_in = (self.fade_in + dt / FADE_IN).min(1.0);
185        }
186        if self.radius_t < 1.0 {
187            self.radius_t = (self.radius_t + dt / self.radius_seconds).min(1.0);
188        }
189        if self.fade_out_rate > 0.0 && self.fade_out < 1.0 {
190            self.fade_out = (self.fade_out + dt * self.fade_out_rate).min(1.0);
191        }
192        !self.finished()
193    }
194
195    /// A held ripple is never finished: it is waiting for a finger to lift.
196    fn finished(&self) -> bool {
197        !self.held() && self.fade_out >= 1.0
198    }
199
200    /// The opacity multiplier for this frame, `0..=1`.
201    fn alpha(&self) -> f32 {
202        if self.fade_in_running && self.fade_in < 1.0 {
203            self.fade_in
204        } else {
205            1.0 - ((self.fade_out - FADE_OUT_START) / (1.0 - FADE_OUT_START)).clamp(0.0, 1.0)
206        }
207    }
208
209    /// The circle to paint this frame, in the box's own coordinates.
210    fn circle(&self) -> (Point, f32) {
211        let t = Curve::ease().transform(self.radius_t);
212        let radius = self.target * START_FRACTION
213            + (self.target + OVERSHOOT - self.target * START_FRACTION) * t;
214        let centre = Point::new(
215            self.origin.x + (self.centre.x - self.origin.x) * t,
216            self.origin.y + (self.centre.y - self.origin.y) * t,
217        );
218        (centre, radius)
219    }
220}
221
222/// The radius a splash grows to: **half the box's diagonal**, so that a tap in any
223/// corner still covers the opposite one.
224///
225/// The reference writes this as `max(d1, d2) / 2` over the box's two diagonals, which
226/// are the same length for a rectangle; the maximum is left over from a shape that
227/// could once be something else.
228fn target_radius(size: Size) -> f32 {
229    (size.width * size.width + size.height * size.height).sqrt() / 2.0
230}
231
232/// Every splash currently alive on one widget.
233///
234/// There can be several: a second tap does not cancel the first one's fade, it starts
235/// its own circle beside it — which is what makes drumming on a surface look like
236/// water rather than a switch.
237#[derive(Clone, Debug, Default)]
238pub struct Ripples {
239    list: Vec<Ripple>,
240}
241
242/// Beyond this many live splashes on one widget, the oldest is dropped. A finger
243/// cannot produce them this fast; a stuck event source can.
244const MAX_RIPPLES: usize = 8;
245
246impl Ripples {
247    /// A finger landed at `origin`, in the coordinates of a box of `size`.
248    pub fn press(&mut self, origin: Point, size: Size) {
249        if size.width <= 0.0 || size.height <= 0.0 {
250            return;
251        }
252        if self.list.len() >= MAX_RIPPLES {
253            self.list.remove(0);
254        }
255        self.list.push(Ripple::new(origin, size));
256    }
257
258    /// The tap completed. Only the splash still waiting for it is confirmed.
259    pub fn confirm(&mut self) {
260        if let Some(last) = self.list.iter_mut().rev().find(|r| r.held()) {
261            last.confirm();
262        }
263    }
264
265    /// The gesture ended without a tap: every waiting splash leaves.
266    pub fn cancel(&mut self) {
267        for ripple in self.list.iter_mut().filter(|r| r.held()) {
268            ripple.cancel();
269        }
270    }
271
272    /// Advances every splash, dropping the ones that have gone. Returns `true` while
273    /// any is still moving.
274    pub fn advance(&mut self, dt: f32) -> bool {
275        let mut animating = false;
276        self.list.retain_mut(|ripple| {
277            animating |= ripple.advance(dt);
278            !ripple.finished()
279        });
280        animating
281    }
282
283    /// Is there nothing left to draw?
284    pub fn is_idle(&self) -> bool {
285        self.list.is_empty()
286    }
287
288    /// Folds the splashes' motion into a hash — what the paint-boundary cache compares
289    /// to notice that a cached surface no longer paints what it did last frame.
290    pub fn hash_state<H: std::hash::Hasher>(&self, h: &mut H) {
291        use std::hash::Hash;
292        self.list.len().hash(h);
293        for ripple in &self.list {
294            ripple.radius_t.to_bits().hash(h);
295            ripple.fade_in.to_bits().hash(h);
296            ripple.fade_out.to_bits().hash(h);
297        }
298    }
299
300    /// Paints the splashes into `bounds`, clipped to the box and its rounding.
301    ///
302    /// The circles go into a composited layer whose shape erases whatever runs past
303    /// the corners — the same machinery [`crate::ClipRRect`] uses, and the reason ink
304    /// hugs a rounded button instead of squaring it off.
305    pub fn paint(
306        &self,
307        owner: u64,
308        bounds: Rect,
309        radius: BorderRadius,
310        color: Color,
311        scene: &mut Scene,
312    ) {
313        let start = scene.primitives().len();
314        for ripple in &self.list {
315            let alpha = ripple.alpha();
316            if alpha <= 0.002 {
317                continue;
318            }
319            let (centre, r) = ripple.circle();
320            let circle = Rect::new(
321                bounds.x + centre.x - r,
322                bounds.y + centre.y - r,
323                r * 2.0,
324                r * 2.0,
325            );
326            scene.fill_path(&Path::oval(circle), color.fade(alpha));
327        }
328        let group = scene.split_off(start);
329        if group.is_empty() {
330            return;
331        }
332        scene.push_primitive(Primitive::Layer {
333            primitives: group,
334            opacity: 1.0,
335            clip: bounds,
336            clip_shape: if radius == BorderRadius::ZERO {
337                ClipShape::Rect
338            } else {
339                ClipShape::RRect(radius)
340            },
341            transform: None,
342            filter: LayerFilter::NONE,
343            owner,
344        });
345    }
346}
347
348/// A box that **splashes when tapped** — the reference's `InkWell`.
349///
350/// A pass-through in layout, like [`crate::ClipRRect`]: it takes the size its parent
351/// gives it and its child fills it. What it adds is the ink, and the tap that starts
352/// it.
353///
354/// ```ignore
355/// InkWell::new()
356///     .radius(12.0)                          // the ink is clipped to this rounding
357///     .on_click(Msg::Open(id))
358///     .child(row)
359///
360/// InkWell::new().color(theme.scheme.primary.fade(0.16)).child(tile)   // its own colour
361/// ```
362///
363/// The splash sits **over this widget and under its child**, so a row of text tapped
364/// this way keeps its text on top of the ink, the way a material surface does.
365pub struct InkWell<Msg = crate::callback::Callback> {
366    /// `None` = the theme's splash, resolved at paint time so a theme swap is followed.
367    color: Option<Color>,
368    radius: BorderRadius,
369    on_click: Option<Msg>,
370    on_long_press: Option<Msg>,
371    children: Vec<Box<dyn Widget<Msg>>>,
372}
373
374impl<Msg> Default for InkWell<Msg> {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380impl<Msg> InkWell<Msg> {
381    /// An ink well with the theme's splash colour and square corners.
382    pub fn new() -> Self {
383        Self {
384            color: None,
385            radius: BorderRadius::ZERO,
386            on_click: None,
387            on_long_press: None,
388            children: Vec::new(),
389        }
390    }
391
392    /// Overrides the splash colour, **alpha included** — the caller decides how
393    /// present the ink is, not just what hue it takes.
394    pub fn color(mut self, color: Color) -> Self {
395        self.color = Some(color);
396        self
397    }
398
399    /// The rounding the ink is clipped to (uniform via `f32`, per corner via
400    /// [`BorderRadius`]). It should match whatever draws the surface underneath.
401    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
402        self.radius = radius.into();
403        self
404    }
405
406    /// The message a tap emits.
407    pub fn on_click(mut self, msg: impl Into<Msg>) -> Self {
408        self.on_click = Some(msg.into());
409        self
410    }
411
412    /// The message a long press emits.
413    pub fn on_long_press(mut self, msg: impl Into<Msg>) -> Self {
414        self.on_long_press = Some(msg.into());
415        self
416    }
417
418    /// Sets the child.
419    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
420        self.children.clear();
421        self.children.push(Box::new(child));
422        self
423    }
424}
425
426impl<Msg: Clone> Widget<Msg> for InkWell<Msg> {
427    fn style(&self) -> Style {
428        // A pass-through: the box is sized by the context, like the child.
429        Style::default()
430    }
431
432    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
433        &self.children
434    }
435
436    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
437        // Nothing of its own: the ink is painted by the walk, from `ink()`.
438    }
439
440    fn ink(&self, theme: &Theme) -> Option<InkStyle> {
441        Some(
442            InkStyle::of(theme)
443                .color(self.color.unwrap_or_else(|| default_splash(theme)))
444                .radius(self.radius),
445        )
446    }
447
448    fn on_click(&self) -> Option<Msg> {
449        self.on_click.clone()
450    }
451
452    fn on_long_press(&self) -> Option<Msg> {
453        self.on_long_press.clone()
454    }
455
456    fn debug_name(&self) -> &'static str {
457        "InkWell"
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    const BOX: Size = Size {
466        width: 100.0,
467        height: 60.0,
468    };
469
470    fn pressed(origin: Point) -> Ripples {
471        let mut ripples = Ripples::default();
472        ripples.press(origin, BOX);
473        ripples
474    }
475
476    #[test]
477    fn a_splash_starts_where_the_finger_landed_and_at_thirty_percent() {
478        let ripples = pressed(Point::new(10.0, 10.0));
479        let (centre, radius) = ripples.list[0].circle();
480        assert!(
481            (centre.x - 10.0).abs() < 0.01 && (centre.y - 10.0).abs() < 0.01,
482            "it starts under the finger: {centre:?}"
483        );
484        let target = target_radius(BOX);
485        assert!(
486            (radius - target * 0.30).abs() < 1e-3,
487            "the first frame is 30% of the target radius, got {radius} of {target}"
488        );
489    }
490
491    #[test]
492    fn the_target_radius_covers_the_far_corner() {
493        // A tap in one corner has to reach the opposite one, which is the whole
494        // diagonal away — hence half of it from the middle the splash drifts to.
495        let target = target_radius(BOX);
496        let half_diagonal = (100.0f32.powi(2) + 60.0f32.powi(2)).sqrt() / 2.0;
497        assert!((target - half_diagonal).abs() < 1e-3);
498    }
499
500    #[test]
501    fn a_held_splash_swells_slowly_and_never_leaves() {
502        let mut ripples = pressed(Point::new(50.0, 30.0));
503        // A quarter of a second in, a *confirmed* splash would be done growing. Held,
504        // it is only a quarter of the way through its one-second timeline.
505        for _ in 0..15 {
506            ripples.advance(1.0 / 60.0);
507        }
508        let ripple = ripples.list[0];
509        assert!(
510            ripple.radius_t > 0.2 && ripple.radius_t < 0.3,
511            "held, it is only a quarter of the way out: {}",
512            ripple.radius_t
513        );
514        assert!(!ripple.finished(), "and it waits for the finger");
515        assert_eq!(
516            ripple.alpha(),
517            1.0,
518            "though it reached full opacity long ago"
519        );
520    }
521
522    #[test]
523    fn a_confirmed_splash_fades_out_and_is_dropped() {
524        let mut ripples = pressed(Point::new(50.0, 30.0));
525        ripples.advance(1.0 / 60.0);
526        ripples.confirm();
527        let mut frames = 0;
528        while ripples.advance(1.0 / 60.0) && frames < 200 {
529            frames += 1;
530        }
531        assert!(ripples.is_idle(), "the splash is gone");
532        // 375 ms of fade-out, minus the frame already spent: around 22 frames at 60 Hz.
533        assert!(
534            (20..=24).contains(&frames),
535            "it takes the reference's 375 ms, got {frames} frames"
536        );
537    }
538
539    #[test]
540    fn the_ink_holds_its_colour_before_it_starts_leaving() {
541        let mut ripples = pressed(Point::new(50.0, 30.0));
542        for _ in 0..6 {
543            ripples.advance(1.0 / 60.0);
544        }
545        ripples.confirm();
546        // 150 ms into the 375 ms fade-out — still inside the 225 ms the reference
547        // spends holding, so the ink is at full strength while the circle grows.
548        for _ in 0..9 {
549            ripples.advance(1.0 / 60.0);
550        }
551        assert_eq!(ripples.list[0].alpha(), 1.0);
552        // 300 ms in — past the hold, and 150 ms of the 150 ms that are left to fade have
553        // run half way: the ink drops from full to nothing in the last two fifths.
554        for _ in 0..9 {
555            ripples.advance(1.0 / 60.0);
556        }
557        let alpha = ripples.list[0].alpha();
558        assert!(
559            (alpha - 0.5).abs() < 0.05,
560            "past the hold it drops fast: {alpha}"
561        );
562    }
563
564    #[test]
565    fn a_cancelled_splash_leaves_five_times_faster_than_a_confirmed_one() {
566        let mut held = pressed(Point::new(50.0, 30.0));
567        for _ in 0..6 {
568            held.advance(1.0 / 60.0); // past the fade-in
569        }
570        let mut cancelled = held.clone();
571        held.confirm();
572        cancelled.cancel();
573
574        let count = |mut r: Ripples| {
575            let mut frames = 0;
576            while r.advance(1.0 / 60.0) && frames < 200 {
577                frames += 1;
578            }
579            frames
580        };
581        let (confirmed_frames, cancelled_frames) = (count(held), count(cancelled));
582        assert!(
583            confirmed_frames > cancelled_frames * 3,
584            "a cancel is 75 ms against 375: {cancelled_frames} frames against {confirmed_frames}"
585        );
586    }
587
588    #[test]
589    fn the_splash_drifts_to_the_middle_of_the_box() {
590        let mut ripples = pressed(Point::new(5.0, 5.0));
591        ripples.confirm();
592        // 250 ms: the radius timeline (225 ms) is over, and the ink has not yet faded
593        // out (375 ms) — the one moment where the splash is fully grown and still there.
594        for _ in 0..15 {
595            ripples.advance(1.0 / 60.0);
596        }
597        let (centre, _) = ripples.list[0].circle();
598        assert!(
599            (centre.x - 50.0).abs() < 0.5 && (centre.y - 30.0).abs() < 0.5,
600            "it ends up centred: {centre:?}"
601        );
602    }
603
604    #[test]
605    fn two_taps_splash_twice() {
606        let mut ripples = pressed(Point::new(10.0, 10.0));
607        ripples.confirm();
608        ripples.advance(1.0 / 60.0);
609        ripples.press(Point::new(90.0, 50.0), BOX);
610        assert_eq!(ripples.list.len(), 2, "the first fade is not interrupted");
611        // The confirm belongs to the second tap: the first is already leaving.
612        ripples.confirm();
613        assert!(!ripples.list[1].held());
614    }
615
616    #[test]
617    fn a_press_on_an_empty_box_splashes_nothing() {
618        let mut ripples = Ripples::default();
619        ripples.press(Point::new(0.0, 0.0), Size::new(0.0, 0.0));
620        assert!(ripples.is_idle());
621    }
622
623    #[test]
624    fn the_ink_is_clipped_to_the_rounding_it_was_given() {
625        let mut ripples = pressed(Point::new(50.0, 30.0));
626        ripples.advance(1.0 / 60.0);
627        let mut scene = Scene::new();
628        let bounds = Rect::new(0.0, 0.0, 100.0, 60.0);
629        ripples.paint(
630            7,
631            bounds,
632            BorderRadius::uniform(12.0),
633            Color::WHITE,
634            &mut scene,
635        );
636        assert_eq!(scene.primitives().len(), 1, "one composited layer");
637        match &scene.primitives()[0] {
638            Primitive::Layer {
639                clip_shape,
640                clip,
641                owner,
642                ..
643            } => {
644                assert_eq!(*clip_shape, ClipShape::RRect(BorderRadius::uniform(12.0)));
645                assert_eq!(*clip, bounds, "ink never escapes its box");
646                assert_eq!(*owner, 7);
647            }
648            other => panic!("expected a layer, got {other:?}"),
649        }
650    }
651}