Skip to main content

a7n_platform/feedback/
look.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Local edge appearance and shared beam geometry in top-left coordinates.
4
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8
9use super::parameters;
10
11#[cfg(not(target_os = "macos"))]
12use crate::input::Bounds;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Style {
17    Sparks,
18    Waves,
19    Clouds,
20    Sunrise,
21    Aurora,
22    Arcs,
23    Ferrofluid,
24    #[default]
25    #[serde(other)]
26    Membrane,
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Landing {
32    #[default]
33    Ring,
34    Pulse,
35    Dot,
36}
37
38impl Landing {
39    pub(super) const fn radius(self) -> f64 {
40        match self {
41            Self::Ring => 80.0,
42            Self::Pulse => 110.0,
43            Self::Dot => 70.0,
44        }
45    }
46
47    pub(super) const fn band(self) -> f64 {
48        match self {
49            Self::Ring => 28.0,
50            Self::Pulse => 24.0,
51            Self::Dot => 0.0,
52        }
53    }
54
55    /// Radius relative to this shape's maximum, and opacity, at this instant.
56    pub(super) fn frame(self, elapsed: Duration, duration: Duration, animate: bool) -> (f64, f64) {
57        const PULSE_START: f64 = 30.0 / Landing::Pulse.radius();
58        let progress = (elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0);
59        let motion = if animate { progress } else { 0.0 };
60        let radius = match self {
61            Self::Ring => 1.0,
62            Self::Pulse if !animate => 1.0,
63            Self::Pulse => PULSE_START + (1.0 - PULSE_START) * motion,
64            Self::Dot => 1.0 - motion,
65        };
66        let alpha = if progress >= 1.0 {
67            0.0
68        } else if animate {
69            1.0 - progress
70        } else {
71            1.0
72        };
73        (radius, alpha)
74    }
75}
76
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79#[repr(u8)]
80pub enum Color {
81    #[default]
82    Accent,
83    Green,
84    Orange,
85    Pink,
86    Ibtesam,
87    Lunada,
88    Stripe,
89    Ali,
90    Alihossein,
91    Transfile,
92    ElectricViolet,
93    Martini,
94    Superman,
95    EdsSunsetGradient,
96    SublimeLight,
97    Argon,
98    #[serde(alias = "jshine")]
99    JShine,
100    Flare,
101    UltraVoilet,
102    KyooPal,
103    KyeMeh,
104    Wiretap,
105    Rastafari,
106    SinCityRed,
107    AzurLane,
108    WitchingHour,
109}
110
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Size {
114    Small,
115    #[default]
116    Medium,
117    Large,
118}
119
120impl Size {
121    #[cfg(all(unix, not(target_os = "macos")))]
122    // Physical pixel dimensions are rounded after applying the local scale.
123    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
124    pub(super) fn pixels(self, base: u32) -> u32 {
125        (f64::from(base) * self.multiplier()).round().max(1.0) as u32
126    }
127
128    #[must_use]
129    pub(super) const fn multiplier(self) -> f64 {
130        match self {
131            Self::Small => 0.5,
132            Self::Medium => 1.0,
133            Self::Large => 2.0,
134        }
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
139#[serde(default, deny_unknown_fields)]
140pub struct Look {
141    pub style: Style,
142    #[serde(rename = "landing_style")]
143    pub landing: Landing,
144    pub color: Color,
145    pub size: Size,
146    pub depth: f32,
147    pub radius: f32,
148    pub speed: f32,
149    pub frequency: f32,
150    pub intensity: f32,
151    #[serde(skip)]
152    pub show_edge: bool,
153    #[serde(skip)]
154    pub show_marker: bool,
155}
156
157pub(super) const LANDING_SOFT_EDGE: f64 = 4.0;
158
159impl Look {
160    pub(super) fn arrival_diameter(self) -> f64 {
161        (self.landing.radius() + LANDING_SOFT_EDGE) * 2.0 * self.size.multiplier()
162    }
163}
164
165impl Default for Look {
166    fn default() -> Self {
167        Self {
168            style: Style::default(),
169            landing: Landing::default(),
170            color: Color::default(),
171            size: Size::default(),
172            depth: parameters::DEPTH.default,
173            radius: parameters::RADIUS.default,
174            speed: parameters::SPEED.default,
175            frequency: parameters::FREQUENCY.default,
176            intensity: parameters::INTENSITY.default,
177            show_edge: true,
178            show_marker: true,
179        }
180    }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq)]
184pub(super) struct Rect {
185    pub x: f64,
186    pub y: f64,
187    pub width: f64,
188    pub height: f64,
189}
190
191#[cfg(not(target_os = "macos"))]
192impl Rect {
193    #[cfg(all(unix, not(target_os = "macos")))]
194    pub(super) fn from_bounds(bounds: Bounds) -> Self {
195        Self {
196            x: f64::from(bounds.x),
197            y: f64::from(bounds.y),
198            width: f64::from(bounds.width),
199            height: f64::from(bounds.height),
200        }
201    }
202
203    // Desktop rectangles are bounded by the OS; integer backends need pixel rounding.
204    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
205    pub(super) fn bounds(self) -> Bounds {
206        Bounds {
207            x: self.x.round() as i32,
208            y: self.y.round() as i32,
209            width: self.width.round().max(1.0) as u32,
210            height: self.height.round().max(1.0) as u32,
211        }
212    }
213}
214
215/// Keep expanding arrivals on the landing monitor, including points at its edge.
216pub(super) fn landing_rect(area: Rect, point: (f64, f64), diameter: f64) -> Rect {
217    let width = diameter.min(area.width);
218    let height = diameter.min(area.height);
219    Rect {
220        x: (point.0 - width / 2.0).clamp(area.x, area.x + area.width - width),
221        y: (point.1 - height / 2.0).clamp(area.y, area.y + area.height - height),
222        width,
223        height,
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn frame_elapsed_pulse_grows_and_dot_shrinks() {
233        // Arrange
234        let duration = Duration::from_millis(400);
235        for (style, grows) in [(Landing::Pulse, true), (Landing::Dot, false)] {
236            // Act
237            let start = style.frame(Duration::ZERO, duration, true);
238            let later = style.frame(duration / 2, duration, true);
239            let reduced = style.frame(duration / 2, duration, false);
240
241            // Assert
242            assert!(if grows {
243                later.0 > start.0
244            } else {
245                later.0 < start.0
246            });
247            assert!(later.1 < start.1);
248            assert_eq!(reduced, style.frame(Duration::ZERO, duration, false));
249            assert_eq!(style.frame(duration, duration, true).1, 0.0);
250        }
251    }
252}