Coverage Report

Created: 2026-07-21 00:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-cosmic-applet/src/ui/components/working_animations/droplet.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
use std::f32::consts::PI;
3
4
use cosmic::{
5
    Renderer,
6
    iced::{
7
        Color, Point,
8
        widget::canvas::{Frame, path, stroke},
9
    },
10
};
11
12
use super::{WorkingAnimationRenderer, WorkingDrawContext, logical_span, smoothstep};
13
14
const PERIOD_MS: f32 = 1900.0;
15
const RAMP_MS: f32 = 260.0;
16
17
/// Vertical offset (px, upward positive) of the rippling line at distance `d`
18
/// (px) from the ripple origin. `half_width` is the distance from the origin
19
/// to the far edge of the *logical full* animation, so the logical full width
20
/// is `2 * half_width` (for a `Full` applet that's the applet width; for a side
21
/// applet it's twice the applet width, with the origin at the inner edge).
22
/// Pure + deterministic so it can be unit-tested without a renderer.
23
#[allow(clippy::many_single_char_names)] // math variables: d, h, c, k are standard notation
24
484
pub(crate) fn droplet_offset(d: f32, half_width: f32, elapsed_ms: f32, h: f32) -> f32 {
25
484
    if half_width <= 0.0 {
26
0
        return 0.0;
27
484
    }
28
484
    let wfull = 2.0 * half_width;
29
484
    let c = wfull / 1700.0;
30
484
    let k = 3.0 * 4.0 * PI / wfull;
31
484
    let omega = c * k;
32
484
    let a0 = h * 0.38;
33
484
    let spatial = 2.2 / wfull;
34
484
    let tdecay = 0.0026;
35
968
    let 
drop484
= |age: f32| -> f32 {
36
968
        let la = age - d / c;
37
968
        if la <= 0.0 {
38
370
            return 0.0;
39
598
        }
40
598
        a0 * (-d * spatial).exp()
41
598
            * (-la * tdecay).exp()
42
598
            * smoothstep(0.0, RAMP_MS, la)
43
598
            * (omega * la).sin()
44
968
    };
45
484
    let age_now = elapsed_ms % PERIOD_MS;
46
484
    drop(age_now) + drop(age_now + PERIOD_MS)
47
484
}
48
49
pub struct DropletAnimation;
50
51
impl WorkingAnimationRenderer for DropletAnimation {
52
    #[allow(clippy::many_single_char_names)] // math variables: x, y, w, h are standard notation
53
0
    fn draw(&self, frame: &mut Frame<Renderer>, ctx: &WorkingDrawContext) {
54
        let WorkingDrawContext {
55
0
            bounds,
56
0
            elapsed_ms,
57
0
            color_config,
58
0
            is_dark,
59
0
            cosmic_theme,
60
0
            side,
61
0
        } = *ctx;
62
0
        let (w, h) = (bounds.width, bounds.height);
63
0
        let mid_y = bounds.y + h / 2.0;
64
0
        let base = color_config.get_color_with_theme(is_dark, cosmic_theme);
65
66
        // Split the ripple by side: the origin sits at the logical centre
67
        // (`half_width` from each edge of the logical full animation). For a
68
        // side applet that centre lands on its inner edge, so Left + Right form
69
        // one ripple seamed at the panel middle.
70
0
        let (wlog, x_lo) = logical_span(side, w);
71
0
        let half_width = wlog / 2.0;
72
73
0
        let mut builder = path::Builder::new();
74
0
        let mut lx = 0.0;
75
0
        let mut first = true;
76
0
        while lx <= w {
77
0
            let d = (x_lo + lx - half_width).abs();
78
0
            let y = droplet_offset(d, half_width, elapsed_ms, h);
79
0
            let p = Point::new(bounds.x + lx, mid_y - y);
80
0
            if first {
81
0
                builder.move_to(p);
82
0
                first = false;
83
0
            } else {
84
0
                builder.line_to(p);
85
0
            }
86
0
            lx += 2.0;
87
        }
88
0
        let line = builder.build();
89
90
        // Glow approximation: a wide, faint under-stroke beneath the line.
91
0
        frame.stroke(
92
0
            &line,
93
0
            stroke::Stroke {
94
0
                style: stroke::Style::Solid(Color::from_rgba(
95
0
                    base.r,
96
0
                    base.g,
97
0
                    base.b,
98
0
                    base.a * 0.25,
99
0
                )),
100
0
                width: 5.0,
101
0
                line_cap: cosmic::iced::widget::canvas::LineCap::Round,
102
0
                line_join: cosmic::iced::widget::canvas::LineJoin::Round,
103
0
                ..Default::default()
104
0
            },
105
        );
106
0
        frame.stroke(
107
0
            &line,
108
0
            stroke::Stroke {
109
0
                style: stroke::Style::Solid(base),
110
0
                width: 2.4,
111
0
                line_cap: cosmic::iced::widget::canvas::LineCap::Round,
112
0
                line_join: cosmic::iced::widget::canvas::LineJoin::Round,
113
0
                ..Default::default()
114
0
            },
115
        );
116
117
        // Impact flash at the ripple origin (the logical centre) for the first
118
        // ~180ms of each droplet. On a side applet that origin maps to the inner
119
        // edge, so the flash appears at the seam.
120
0
        let impact = (1.0 - (elapsed_ms % PERIOD_MS) / 180.0).max(0.0);
121
0
        if impact > 0.0 {
122
0
            let origin_x = bounds.x + (half_width - x_lo);
123
0
            let dot = path::Path::circle(Point::new(origin_x, mid_y), 2.4 + 3.0 * impact);
124
0
            frame.fill(
125
0
                &dot,
126
0
                Color::from_rgba(base.r, base.g, base.b, base.a * 0.8 * impact),
127
0
            );
128
0
        }
129
0
    }
130
}
131
132
#[cfg(test)]
133
mod tests {
134
    use super::*;
135
136
    #[test]
137
2
    fn near_rest_at_start() {
138
        // Full applet: half_width = w/2, origin (d=0) at the centre.
139
2
        let (w, h) = (240.0_f32, 60.0_f32);
140
2
        let hw = w / 2.0;
141
2
        let mut x = 0.0;
142
244
        while x <= w {
143
242
            let d = (x - hw).abs();
144
242
            let y = droplet_offset(d, hw, 0.0, h);
145
242
            assert!(y.abs() <= 0.02 * h, "offset {y} at x={x} exceeds 2% of H");
146
242
            x += 2.0;
147
        }
148
2
    }
149
150
    #[test]
151
    #[allow(clippy::cast_precision_loss)] // loop counter
152
2
    fn moves_after_a_drop() {
153
2
        let (w, h) = (240.0_f32, 60.0_f32);
154
2
        let hw = w / 2.0;
155
2
        let mut max = 0.0_f32;
156
242
        for i in 
0..=1202
{
157
242
            let d = (i as f32 * 2.0 - hw).abs();
158
242
            max = max.max(droplet_offset(d, hw, 400.0, h).abs());
159
242
        }
160
2
        assert!(
161
2
            max > 0.05 * h,
162
            "expected visible motion mid-cycle, got {max}"
163
        );
164
2
    }
165
}