Skip to main content

edit/
style.rs

1//! The cinematic framing layer — background, cursor, crop, and aspect.
2//!
3//! These configs are consumed by the renderer (`wisp`) at preview +
4//! export time to wrap the raw screen capture in the produced look the
5//! editor's Inspector exposes (ED.15 / ED.18 / ED.19). Default values
6//! mirror the reference design (padding 64 px, corner radius 14 px,
7//! shadow 60, cursor 180 %, auto-zoom hold 1.2 s / max 2.4×).
8
9use serde::{Deserialize, Serialize};
10
11/// The backdrop the framed screen sits on.
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum BackgroundSource {
15    /// A named wallpaper from the bundled set (e.g. `"aurora"`).
16    Wallpaper {
17        /// Wallpaper identifier.
18        name: String,
19    },
20    /// A linear gradient between two RGB colors at `angle_deg`.
21    Gradient {
22        /// Start color, RGB `0..=255`.
23        from: [u8; 3],
24        /// End color, RGB `0..=255`.
25        to: [u8; 3],
26        /// Gradient angle in degrees (0 = left→right, 90 = bottom→top).
27        angle_deg: f32,
28    },
29    /// A flat fill.
30    Color {
31        /// Fill color, RGB `0..=255`.
32        rgb: [u8; 3],
33    },
34}
35
36impl Default for BackgroundSource {
37    fn default() -> Self {
38        // An "Aurora"-style warm→cool diagonal gradient, matching the
39        // reference design's default background swatch.
40        Self::Gradient {
41            from: [255, 138, 128],
42            to: [40, 53, 147],
43            angle_deg: 135.0,
44        }
45    }
46}
47
48/// Background framing: the backdrop plus the padding / rounding / shadow
49/// that lift the screen capture off it.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51#[serde(default)]
52pub struct BackgroundConfig {
53    /// The backdrop fill.
54    #[serde(default)]
55    pub source: BackgroundSource,
56    /// Padding between the backdrop edge and the framed screen, in
57    /// composed-canvas pixels.
58    pub padding: u32,
59    /// Corner radius of the framed screen, in composed-canvas pixels.
60    pub corner_radius: u32,
61    /// Drop-shadow strength, `0..=100`.
62    pub shadow: u32,
63    /// Inset border (a colored frame just inside the screen edge), in
64    /// pixels. `0` disables it.
65    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/// Auto-zoom detection settings (ED.17 generates `Auto` zoom regions
81/// using these thresholds).
82#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
83#[serde(default)]
84pub struct AutoZoomConfig {
85    /// Whether to auto-generate zoom regions from cursor/click telemetry.
86    pub detect_from_cursor: bool,
87    /// How long a generated zoom holds at full amount, in milliseconds.
88    pub hold_time_ms: u32,
89    /// Maximum zoom factor an auto-generated region may reach.
90    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/// Cursor styling and smoothing (ED.19 renders a cursor overlay driven
104/// by these settings + the captured cursor track).
105#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
106#[serde(default)]
107pub struct CursorConfig {
108    /// Cursor size as a percentage of its native size, e.g. `180`.
109    pub size_pct: u32,
110    /// Motion smoothing strength, `0..=100`.
111    pub smoothing: u32,
112    /// Render a ripple effect on clicks.
113    pub click_ripples: bool,
114    /// Hide the cursor while it is stationary.
115    pub hide_static: bool,
116    /// Auto-zoom detection settings (shown under the cursor inspector in
117    /// the reference design).
118    #[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/// A crop rectangle, normalized to `[0, 1]` of the source frame:
135/// `(x, y)` is the top-left corner, `(width, height)` the extent. The
136/// full frame is `{ x: 0, y: 0, width: 1, height: 1 }`.
137#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
138pub struct CropRect {
139    /// Left edge, `0.0..=1.0`.
140    pub x: f32,
141    /// Top edge, `0.0..=1.0`.
142    pub y: f32,
143    /// Width, `0.0..=1.0`.
144    pub width: f32,
145    /// Height, `0.0..=1.0`.
146    pub height: f32,
147}
148
149impl CropRect {
150    /// The full, uncropped frame.
151    #[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    /// Whether this rect is (approximately) the full frame.
162    #[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/// Output aspect ratio. Drives the composed-canvas dimensions; changing
178/// it reframes the export (e.g. a 16:9 desktop recording → a 9:16 short).
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum AspectRatio {
182    /// 16:9 widescreen — the default.
183    #[default]
184    Wide,
185    /// 9:16 vertical (shorts / reels).
186    Vertical,
187    /// 1:1 square.
188    Square,
189    /// 4:3 classic.
190    Classic,
191}
192
193impl AspectRatio {
194    /// The width:height ratio as integers.
195    #[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    /// Canvas pixel dimensions whose longer edge is `long_edge`,
206    /// preserving the ratio. Both edges are rounded down to even numbers
207    /// (H.264 chroma subsampling requires even dimensions).
208    #[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        // 1920 long edge, 16:9 → 1920×1080.
267        assert_eq!(AspectRatio::Wide.canvas_dims(1920), (1920, 1080));
268        // 9:16 vertical with long edge 1920 → 1080×1920.
269        assert_eq!(AspectRatio::Vertical.canvas_dims(1920), (1080, 1920));
270        // Square.
271        assert_eq!(AspectRatio::Square.canvas_dims(1080), (1080, 1080));
272        // Even-dimension guarantee.
273        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        // ED.23 forward-compat: container `#[serde(default)]` fills any
281        // missing field from the struct's (design-meaningful) Default.
282        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}