Skip to main content

rustmotion_components/
mockup.rs

1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, Paint, PaintStyle, PathBuilder, RRect, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{asset_cache, paint_from_hex};
10use rustmotion_core::error::RustmotionError;
11use rustmotion_core::schema::TimelineStep;
12use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
13
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "snake_case")]
16#[derive(Default)]
17pub enum MockupDevice {
18    #[default]
19    Iphone,
20    Android,
21    Laptop,
22    Browser,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27#[derive(Default)]
28pub enum MockupTheme {
29    #[default]
30    Dark,
31    Light,
32}
33
34impl MockupTheme {
35    fn bezel_color(&self) -> &str {
36        match self {
37            MockupTheme::Dark => "#1A1A1A",
38            MockupTheme::Light => "#E5E7EB",
39        }
40    }
41
42    fn screen_bg(&self) -> &str {
43        match self {
44            MockupTheme::Dark => "#000000",
45            MockupTheme::Light => "#FFFFFF",
46        }
47    }
48}
49
50#[derive(Debug, Serialize, Deserialize, JsonSchema)]
51pub struct Mockup {
52    pub device: MockupDevice,
53    pub src: String,
54    #[serde(default)]
55    pub theme: MockupTheme,
56    #[serde(flatten)]
57    pub timing: TimingConfig,
58    #[serde(default)]
59    pub style: CssStyle,
60    #[serde(default)]
61    pub timeline: Vec<TimelineStep>,
62    #[serde(default)]
63    pub stagger: Option<f32>,
64}
65
66rustmotion_core::impl_traits!(Mockup {
67    Animatable => animation,
68    Timed => timing,
69    Styled => style,
70});
71
72struct DeviceMetrics {
73    corner_radius: f32,
74    bezel_top: f32,
75    bezel_bottom: f32,
76    bezel_side: f32,
77}
78
79impl MockupDevice {
80    fn metrics(&self) -> DeviceMetrics {
81        match self {
82            MockupDevice::Iphone => DeviceMetrics {
83                corner_radius: 40.0,
84                bezel_top: 44.0,
85                bezel_bottom: 34.0,
86                bezel_side: 12.0,
87            },
88            MockupDevice::Android => DeviceMetrics {
89                corner_radius: 35.0,
90                bezel_top: 32.0,
91                bezel_bottom: 24.0,
92                bezel_side: 10.0,
93            },
94            MockupDevice::Laptop => DeviceMetrics {
95                corner_radius: 12.0,
96                bezel_top: 32.0,
97                bezel_bottom: 32.0,
98                bezel_side: 16.0,
99            },
100            MockupDevice::Browser => DeviceMetrics {
101                corner_radius: 10.0,
102                bezel_top: 40.0,
103                bezel_bottom: 0.0,
104                bezel_side: 0.0,
105            },
106        }
107    }
108}
109
110impl Mockup {
111    fn load_content_image(&self) -> Result<skia_safe::Image> {
112        let cache = asset_cache();
113        if let Some(cached) = cache.get(&self.src) {
114            return Ok(cached.clone());
115        }
116
117        let data = std::fs::read(&self.src).map_err(|e| RustmotionError::ImageLoad {
118            path: self.src.clone(),
119            reason: e.to_string(),
120        })?;
121        let skia_data = skia_safe::Data::new_copy(&data);
122        let decoded = skia_safe::Image::from_encoded(skia_data).ok_or_else(|| {
123            RustmotionError::ImageDecode {
124                path: self.src.clone(),
125            }
126        })?;
127        cache.insert(self.src.clone(), decoded.clone());
128        Ok(decoded)
129    }
130
131    fn draw_content_image(&self, canvas: &Canvas, screen_rect: Rect) -> Result<()> {
132        let img = self.load_content_image()?;
133        let img_w = img.width() as f32;
134        let img_h = img.height() as f32;
135        let sw = screen_rect.width();
136        let sh = screen_rect.height();
137
138        // Cover fit
139        let scale = (sw / img_w).max(sh / img_h);
140        let draw_w = img_w * scale;
141        let draw_h = img_h * scale;
142        let offset_x = screen_rect.left + (sw - draw_w) / 2.0;
143        let offset_y = screen_rect.top + (sh - draw_h) / 2.0;
144
145        canvas.save();
146        canvas.clip_rect(screen_rect, skia_safe::ClipOp::Intersect, true);
147        let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
148        canvas.draw_image_rect(img, None, dst, &Paint::default());
149        canvas.restore();
150
151        Ok(())
152    }
153
154    fn render_phone(&self, canvas: &Canvas, w: f32, h: f32, m: &DeviceMetrics) -> Result<()> {
155        // Device body
156        let body_rect = Rect::from_xywh(0.0, 0.0, w, h);
157        let body_rrect = RRect::new_rect_xy(body_rect, m.corner_radius, m.corner_radius);
158        let mut body_paint = paint_from_hex(self.theme.bezel_color());
159        body_paint.set_style(PaintStyle::Fill);
160        body_paint.set_anti_alias(true);
161        canvas.draw_rrect(body_rrect, &body_paint);
162
163        // Screen area
164        let screen_rect = Rect::from_xywh(
165            m.bezel_side,
166            m.bezel_top,
167            w - m.bezel_side * 2.0,
168            h - m.bezel_top - m.bezel_bottom,
169        );
170
171        // Screen background
172        let mut screen_bg = paint_from_hex(self.theme.screen_bg());
173        screen_bg.set_style(PaintStyle::Fill);
174        let screen_radius = m.corner_radius - m.bezel_side;
175        let screen_rrect =
176            RRect::new_rect_xy(screen_rect, screen_radius.max(0.0), screen_radius.max(0.0));
177        canvas.draw_rrect(screen_rrect, &screen_bg);
178
179        // Content image
180        canvas.save();
181        canvas.clip_rrect(screen_rrect, skia_safe::ClipOp::Intersect, true);
182        self.draw_content_image(canvas, screen_rect)?;
183        canvas.restore();
184
185        // iPhone notch
186        if matches!(self.device, MockupDevice::Iphone) {
187            let notch_w = w * 0.35;
188            let notch_h = 28.0;
189            let notch_x = (w - notch_w) / 2.0;
190            let notch_r = 12.0;
191
192            let mut notch_paint = paint_from_hex(self.theme.bezel_color());
193            notch_paint.set_style(PaintStyle::Fill);
194            notch_paint.set_anti_alias(true);
195
196            let notch_rect = Rect::from_xywh(notch_x, 0.0, notch_w, notch_h);
197            let notch_rrect = RRect::new_rect_radii(
198                notch_rect,
199                &[
200                    (0.0, 0.0).into(),
201                    (0.0, 0.0).into(),
202                    (notch_r, notch_r).into(),
203                    (notch_r, notch_r).into(),
204                ],
205            );
206            canvas.draw_rrect(notch_rrect, &notch_paint);
207        }
208
209        // Android punch-hole camera
210        if matches!(self.device, MockupDevice::Android) {
211            let mut cam_paint = paint_from_hex(self.theme.bezel_color());
212            cam_paint.set_style(PaintStyle::Fill);
213            cam_paint.set_anti_alias(true);
214            canvas.draw_circle((w / 2.0, m.bezel_top / 2.0), 5.0, &cam_paint);
215        }
216
217        // Home indicator (iPhone)
218        if matches!(self.device, MockupDevice::Iphone) {
219            let indicator_w = w * 0.35;
220            let indicator_h = 5.0;
221            let indicator_x = (w - indicator_w) / 2.0;
222            let indicator_y = h - m.bezel_bottom / 2.0 - indicator_h / 2.0;
223
224            let mut indicator_paint = paint_from_hex("#888888");
225            indicator_paint.set_style(PaintStyle::Fill);
226            indicator_paint.set_anti_alias(true);
227
228            let indicator_rect =
229                Rect::from_xywh(indicator_x, indicator_y, indicator_w, indicator_h);
230            let indicator_rrect =
231                RRect::new_rect_xy(indicator_rect, indicator_h / 2.0, indicator_h / 2.0);
232            canvas.draw_rrect(indicator_rrect, &indicator_paint);
233        }
234
235        Ok(())
236    }
237
238    fn render_laptop(&self, canvas: &Canvas, w: f32, h: f32, m: &DeviceMetrics) -> Result<()> {
239        let screen_h = h * 0.85;
240        let _base_h = h - screen_h;
241
242        // Screen lid
243        let lid_rect = Rect::from_xywh(0.0, 0.0, w, screen_h);
244        let lid_rrect = RRect::new_rect_xy(lid_rect, m.corner_radius, m.corner_radius);
245        let mut lid_paint = paint_from_hex(self.theme.bezel_color());
246        lid_paint.set_style(PaintStyle::Fill);
247        lid_paint.set_anti_alias(true);
248        canvas.draw_rrect(lid_rrect, &lid_paint);
249
250        // Screen area
251        let screen_rect = Rect::from_xywh(
252            m.bezel_side,
253            m.bezel_top,
254            w - m.bezel_side * 2.0,
255            screen_h - m.bezel_top - 8.0,
256        );
257
258        let mut screen_bg = paint_from_hex(self.theme.screen_bg());
259        screen_bg.set_style(PaintStyle::Fill);
260        canvas.draw_rect(screen_rect, &screen_bg);
261
262        // Content
263        self.draw_content_image(canvas, screen_rect)?;
264
265        // Camera dot
266        let mut cam_paint = paint_from_hex("#333333");
267        cam_paint.set_anti_alias(true);
268        canvas.draw_circle((w / 2.0, m.bezel_top / 2.0), 3.0, &cam_paint);
269
270        // Base (trapezoid)
271        let base_y = screen_h;
272        let inset = w * 0.05;
273        let mut base_path = PathBuilder::new();
274        base_path.move_to((inset, base_y));
275        base_path.line_to((w - inset, base_y));
276        base_path.line_to((w + inset, h));
277        base_path.line_to((-inset, h));
278        base_path.close();
279
280        let mut base_paint = paint_from_hex(self.theme.bezel_color());
281        base_paint.set_style(PaintStyle::Fill);
282        base_paint.set_anti_alias(true);
283        canvas.draw_path(&base_path.detach(), &base_paint);
284
285        Ok(())
286    }
287
288    fn render_browser(&self, canvas: &Canvas, w: f32, h: f32, m: &DeviceMetrics) -> Result<()> {
289        // Window background
290        let body_rect = Rect::from_xywh(0.0, 0.0, w, h);
291        let body_rrect = RRect::new_rect_xy(body_rect, m.corner_radius, m.corner_radius);
292        let mut body_paint = paint_from_hex(self.theme.bezel_color());
293        body_paint.set_style(PaintStyle::Fill);
294        body_paint.set_anti_alias(true);
295        canvas.draw_rrect(body_rrect, &body_paint);
296
297        // Title bar
298        let chrome_color = match self.theme {
299            MockupTheme::Dark => "#2D2D2D",
300            MockupTheme::Light => "#F3F3F3",
301        };
302        let mut chrome_paint = paint_from_hex(chrome_color);
303        chrome_paint.set_style(PaintStyle::Fill);
304
305        canvas.save();
306        canvas.clip_rrect(body_rrect, skia_safe::ClipOp::Intersect, true);
307        canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, m.bezel_top), &chrome_paint);
308        canvas.restore();
309
310        // Traffic lights
311        let dot_colors = ["#FF5F57", "#FEBC2E", "#28C840"];
312        let dot_y = m.bezel_top / 2.0;
313        for (i, color) in dot_colors.iter().enumerate() {
314            let dot_x = 16.0 + i as f32 * 20.0;
315            let mut dot_paint = paint_from_hex(color);
316            dot_paint.set_style(PaintStyle::Fill);
317            dot_paint.set_anti_alias(true);
318            canvas.draw_circle((dot_x, dot_y), 6.0, &dot_paint);
319        }
320
321        // URL bar
322        let url_bar_color = match self.theme {
323            MockupTheme::Dark => "#404040",
324            MockupTheme::Light => "#FFFFFF",
325        };
326        let url_bar_x = 80.0;
327        let url_bar_w = w - 160.0;
328        let url_bar_h = 24.0;
329        let url_bar_y = (m.bezel_top - url_bar_h) / 2.0;
330        let url_rect = Rect::from_xywh(url_bar_x, url_bar_y, url_bar_w, url_bar_h);
331        let url_rrect = RRect::new_rect_xy(url_rect, url_bar_h / 2.0, url_bar_h / 2.0);
332        let mut url_paint = paint_from_hex(url_bar_color);
333        url_paint.set_style(PaintStyle::Fill);
334        url_paint.set_anti_alias(true);
335        canvas.draw_rrect(url_rrect, &url_paint);
336
337        // Content area
338        let screen_rect = Rect::from_xywh(0.0, m.bezel_top, w, h - m.bezel_top);
339
340        let mut screen_bg = paint_from_hex(self.theme.screen_bg());
341        screen_bg.set_style(PaintStyle::Fill);
342        canvas.save();
343        canvas.clip_rrect(body_rrect, skia_safe::ClipOp::Intersect, true);
344        canvas.draw_rect(screen_rect, &screen_bg);
345        canvas.restore();
346
347        // Content image
348        canvas.save();
349        canvas.clip_rrect(body_rrect, skia_safe::ClipOp::Intersect, true);
350        self.draw_content_image(canvas, screen_rect)?;
351        canvas.restore();
352
353        Ok(())
354    }
355}
356
357impl Painter for Mockup {
358    fn paint_content(
359        &self,
360        canvas: &Canvas,
361        layout: &BoxLayout,
362        _props: &AnimatedProperties,
363        _ctx: &PaintCtx,
364    ) {
365        let w = layout.width;
366        let h = layout.height;
367        let m = self.device.metrics();
368
369        let _ = match self.device {
370            MockupDevice::Iphone | MockupDevice::Android => self.render_phone(canvas, w, h, &m),
371            MockupDevice::Laptop => self.render_laptop(canvas, w, h, &m),
372            MockupDevice::Browser => self.render_browser(canvas, w, h, &m),
373        };
374    }
375}