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