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