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 a7n_proto::v1::Edge;
8use serde::{Deserialize, Serialize};
9
10#[cfg(not(target_os = "macos"))]
11use crate::input::Bounds;
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum Style {
16    #[default]
17    Glow,
18    Beam,
19}
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Landing {
24    #[default]
25    Ring,
26    Pulse,
27    Dot,
28}
29
30impl Landing {
31    /// Radius relative to the backend's full ring, and opacity, at this instant.
32    pub(super) fn frame(self, elapsed: Duration, duration: Duration, animate: bool) -> (f64, f64) {
33        const PULSE_START: f64 = 0.2;
34        const DOT_RADIUS: f64 = 0.35;
35        let progress = (elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0);
36        let motion = if animate { progress } else { 0.0 };
37        let radius = match self {
38            Self::Ring => 1.0,
39            Self::Pulse if !animate => 1.0,
40            Self::Pulse => PULSE_START + (1.0 - PULSE_START) * motion,
41            Self::Dot => DOT_RADIUS * (1.0 - motion),
42        };
43        let alpha = if progress >= 1.0 {
44            0.0
45        } else if animate {
46            1.0 - progress
47        } else {
48            1.0
49        };
50        (radius, alpha)
51    }
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Color {
57    #[default]
58    Accent,
59    Green,
60    Orange,
61    Pink,
62}
63
64impl Color {
65    #[must_use]
66    pub(super) const fn rgb(self, dark: bool) -> [u8; 3] {
67        match self {
68            Self::Accent if !dark => [0x2f, 0x5b, 0xea],
69            Self::Accent => [0x70, 0x88, 0xff],
70            Self::Green => [0x43, 0xc7, 0x7f],
71            Self::Orange => [0xe9, 0xa2, 0x3b],
72            Self::Pink => [0xed, 0x78, 0xb5],
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum Size {
80    Small,
81    #[default]
82    Medium,
83    Large,
84}
85
86impl Size {
87    #[cfg(not(target_os = "macos"))]
88    // Physical pixel dimensions are rounded after applying the local scale.
89    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
90    pub(super) fn pixels(self, base: u32) -> u32 {
91        (f64::from(base) * self.multiplier()).round().max(1.0) as u32
92    }
93
94    #[must_use]
95    pub(super) const fn multiplier(self) -> f64 {
96        match self {
97            Self::Small => 0.5,
98            Self::Medium => 1.0,
99            Self::Large => 2.0,
100        }
101    }
102}
103
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct Look {
107    pub style: Style,
108    #[serde(rename = "landing_style")]
109    pub landing: Landing,
110    pub color: Color,
111    pub size: Size,
112}
113
114pub(super) const BEAM_THICKNESS: f64 = 3.0;
115const PROGRESS_FULL: f64 = 1000.0;
116
117#[derive(Debug, Clone, Copy, PartialEq)]
118pub(super) struct Rect {
119    pub x: f64,
120    pub y: f64,
121    pub width: f64,
122    pub height: f64,
123}
124
125#[cfg(not(target_os = "macos"))]
126impl Rect {
127    #[cfg(all(unix, not(target_os = "macos")))]
128    pub(super) fn from_bounds(bounds: Bounds) -> Self {
129        Self {
130            x: f64::from(bounds.x),
131            y: f64::from(bounds.y),
132            width: f64::from(bounds.width),
133            height: f64::from(bounds.height),
134        }
135    }
136
137    // Desktop rectangles are bounded by the OS; integer backends need pixel rounding.
138    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
139    pub(super) fn bounds(self) -> Bounds {
140        Bounds {
141            x: self.x.round() as i32,
142            y: self.y.round() as i32,
143            width: self.width.round().max(1.0) as u32,
144            height: self.height.round().max(1.0) as u32,
145        }
146    }
147}
148
149pub(super) fn beam_rect(
150    area: Rect,
151    edge: Edge,
152    pointer: (f64, f64),
153    progress: u16,
154    thickness: f64,
155) -> Option<Rect> {
156    let horizontal = match edge {
157        Edge::Top | Edge::Bottom => true,
158        Edge::Left | Edge::Right => false,
159        Edge::Unspecified => return None,
160    };
161    let (start, span, along) = if horizontal {
162        (area.x, area.width, pointer.0)
163    } else {
164        (area.y, area.height, pointer.1)
165    };
166    if span <= 0.0 || thickness <= 0.0 {
167        return None;
168    }
169    let length = (span * f64::from(progress) / PROGRESS_FULL).clamp(1.0_f64.min(span), span);
170    let from = (along - length / 2.0).clamp(start, start + span - length);
171    let thickness = thickness.min(if horizontal { area.height } else { area.width });
172    Some(match edge {
173        Edge::Top => Rect {
174            x: from,
175            y: area.y,
176            width: length,
177            height: thickness,
178        },
179        Edge::Bottom => Rect {
180            x: from,
181            y: area.y + area.height - thickness,
182            width: length,
183            height: thickness,
184        },
185        Edge::Left => Rect {
186            x: area.x,
187            y: from,
188            width: thickness,
189            height: length,
190        },
191        Edge::Right => Rect {
192            x: area.x + area.width - thickness,
193            y: from,
194            width: thickness,
195            height: length,
196        },
197        Edge::Unspecified => return None,
198    })
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn frame_elapsed_pulse_grows_and_dot_shrinks() {
207        // Arrange
208        let duration = Duration::from_millis(400);
209        for (style, grows) in [(Landing::Pulse, true), (Landing::Dot, false)] {
210            // Act
211            let start = style.frame(Duration::ZERO, duration, true);
212            let later = style.frame(duration / 2, duration, true);
213            let reduced = style.frame(duration / 2, duration, false);
214
215            // Assert
216            assert!(if grows {
217                later.0 > start.0
218            } else {
219                later.0 < start.0
220            });
221            assert!(later.1 < start.1);
222            assert_eq!(reduced, style.frame(Duration::ZERO, duration, false));
223            assert_eq!(style.frame(duration, duration, true).1, 0.0);
224        }
225    }
226
227    #[test]
228    fn beam_rect_progress_grows_along_pushed_edge() {
229        // Arrange
230        let area = Rect {
231            x: -100.0,
232            y: 20.0,
233            width: 1000.0,
234            height: 800.0,
235        };
236        for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
237            // Act
238            let small = beam_rect(area, edge, (400.0, 420.0), 250, BEAM_THICKNESS).unwrap();
239            let large = beam_rect(area, edge, (400.0, 420.0), 750, BEAM_THICKNESS).unwrap();
240
241            // Assert
242            for rect in [small, large] {
243                assert!(rect.x >= area.x && rect.y >= area.y);
244                assert!(rect.x + rect.width <= area.x + area.width);
245                assert!(rect.y + rect.height <= area.y + area.height);
246                match edge {
247                    Edge::Top => assert_eq!(rect.y, area.y),
248                    Edge::Bottom => assert_eq!(rect.y + rect.height, area.y + area.height),
249                    Edge::Left => assert_eq!(rect.x, area.x),
250                    Edge::Right => assert_eq!(rect.x + rect.width, area.x + area.width),
251                    Edge::Unspecified => unreachable!(),
252                }
253            }
254            if matches!(edge, Edge::Top | Edge::Bottom) {
255                assert_eq!((small.width, large.width), (250.0, 750.0));
256            } else {
257                assert_eq!((small.height, large.height), (200.0, 600.0));
258            }
259        }
260    }
261}