1use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum BackgroundSource {
15 Wallpaper {
17 name: String,
19 },
20 Gradient {
22 from: [u8; 3],
24 to: [u8; 3],
26 angle_deg: f32,
28 },
29 Color {
31 rgb: [u8; 3],
33 },
34}
35
36impl Default for BackgroundSource {
37 fn default() -> Self {
38 Self::Gradient {
41 from: [255, 138, 128],
42 to: [40, 53, 147],
43 angle_deg: 135.0,
44 }
45 }
46}
47
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51#[serde(default)]
52pub struct BackgroundConfig {
53 #[serde(default)]
55 pub source: BackgroundSource,
56 pub padding: u32,
59 pub corner_radius: u32,
61 pub shadow: u32,
63 pub inset: u32,
66}
67
68impl Default for BackgroundConfig {
69 fn default() -> Self {
70 Self {
71 source: BackgroundSource::default(),
72 padding: 64,
73 corner_radius: 14,
74 shadow: 60,
75 inset: 0,
76 }
77 }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
83#[serde(default)]
84pub struct AutoZoomConfig {
85 pub detect_from_cursor: bool,
87 pub hold_time_ms: u32,
89 pub max_zoom: f64,
91}
92
93impl Default for AutoZoomConfig {
94 fn default() -> Self {
95 Self {
96 detect_from_cursor: true,
97 hold_time_ms: 1200,
98 max_zoom: 2.4,
99 }
100 }
101}
102
103#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
106#[serde(default)]
107pub struct CursorConfig {
108 pub size_pct: u32,
110 pub smoothing: u32,
112 pub click_ripples: bool,
114 pub hide_static: bool,
116 #[serde(default)]
119 pub auto_zoom: AutoZoomConfig,
120}
121
122impl Default for CursorConfig {
123 fn default() -> Self {
124 Self {
125 size_pct: 180,
126 smoothing: 80,
127 click_ripples: true,
128 hide_static: true,
129 auto_zoom: AutoZoomConfig::default(),
130 }
131 }
132}
133
134#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
138pub struct CropRect {
139 pub x: f32,
141 pub y: f32,
143 pub width: f32,
145 pub height: f32,
147}
148
149impl CropRect {
150 #[must_use]
152 pub fn full() -> Self {
153 Self {
154 x: 0.0,
155 y: 0.0,
156 width: 1.0,
157 height: 1.0,
158 }
159 }
160
161 #[must_use]
163 pub fn is_full(self) -> bool {
164 (self.x).abs() < 1e-4
165 && (self.y).abs() < 1e-4
166 && (self.width - 1.0).abs() < 1e-4
167 && (self.height - 1.0).abs() < 1e-4
168 }
169}
170
171impl Default for CropRect {
172 fn default() -> Self {
173 Self::full()
174 }
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum AspectRatio {
182 #[default]
184 Wide,
185 Vertical,
187 Square,
189 Classic,
191}
192
193impl AspectRatio {
194 #[must_use]
196 pub fn ratio(self) -> (u32, u32) {
197 match self {
198 Self::Wide => (16, 9),
199 Self::Vertical => (9, 16),
200 Self::Square => (1, 1),
201 Self::Classic => (4, 3),
202 }
203 }
204
205 #[must_use]
209 pub fn canvas_dims(self, long_edge: u32) -> (u32, u32) {
210 let (rw, rh) = self.ratio();
211 let (w, h) = if rw >= rh {
212 (long_edge, long_edge * rh / rw)
213 } else {
214 (long_edge * rw / rh, long_edge)
215 };
216 (w & !1, h & !1)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn background_defaults_match_reference_design() {
226 let bg = BackgroundConfig::default();
227 assert_eq!(bg.padding, 64);
228 assert_eq!(bg.corner_radius, 14);
229 assert_eq!(bg.shadow, 60);
230 assert_eq!(bg.inset, 0);
231 assert!(matches!(bg.source, BackgroundSource::Gradient { .. }));
232 }
233
234 #[test]
235 fn cursor_defaults_match_reference_design() {
236 let c = CursorConfig::default();
237 assert_eq!(c.size_pct, 180);
238 assert_eq!(c.smoothing, 80);
239 assert!(c.click_ripples);
240 assert!(c.hide_static);
241 assert!(c.auto_zoom.detect_from_cursor);
242 assert_eq!(c.auto_zoom.hold_time_ms, 1200);
243 assert!((c.auto_zoom.max_zoom - 2.4).abs() < 1e-9);
244 }
245
246 #[test]
247 fn crop_full_is_full() {
248 assert!(CropRect::full().is_full());
249 assert!(CropRect::default().is_full());
250 assert!(
251 !CropRect {
252 x: 0.1,
253 y: 0.0,
254 width: 0.8,
255 height: 1.0,
256 }
257 .is_full()
258 );
259 }
260
261 #[test]
262 fn aspect_ratios_and_canvas_dims() {
263 assert_eq!(AspectRatio::default(), AspectRatio::Wide);
264 assert_eq!(AspectRatio::Wide.ratio(), (16, 9));
265 assert_eq!(AspectRatio::Vertical.ratio(), (9, 16));
266 assert_eq!(AspectRatio::Wide.canvas_dims(1920), (1920, 1080));
268 assert_eq!(AspectRatio::Vertical.canvas_dims(1920), (1080, 1920));
270 assert_eq!(AspectRatio::Square.canvas_dims(1080), (1080, 1080));
272 let (w, h) = AspectRatio::Classic.canvas_dims(1001);
274 assert_eq!(w % 2, 0);
275 assert_eq!(h % 2, 0);
276 }
277
278 #[test]
279 fn config_partial_json_fills_missing_fields_from_default() {
280 let bg: BackgroundConfig = serde_json::from_str("{}").unwrap();
283 assert_eq!(bg, BackgroundConfig::default());
284 let cur: CursorConfig = serde_json::from_str(r#"{"size_pct": 200}"#).unwrap();
285 assert_eq!(cur.size_pct, 200);
286 assert_eq!(
287 cur.smoothing,
288 CursorConfig::default().smoothing,
289 "missing field defaulted, not zeroed"
290 );
291 let az: AutoZoomConfig = serde_json::from_str("{}").unwrap();
292 assert_eq!(az, AutoZoomConfig::default());
293 }
294}