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 | | } |