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 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 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 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 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 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 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, ¬ch_paint);
207 }
208
209 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) {
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);
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 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 self.draw_content_image(canvas, screen_rect)?;
264
265 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 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 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 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 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 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 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 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}