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, ¬ch_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}