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