1use 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 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 #[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 #[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 let duration = Duration::from_millis(400);
209 for (style, grows) in [(Landing::Pulse, true), (Landing::Dot, false)] {
210 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!(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 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 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 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}