1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, RRect, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex,
11 typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_width() -> f32 {
17 360.0
18}
19fn default_slide_in_at() -> f64 {
20 0.5
21}
22fn default_slide_duration() -> f64 {
23 0.15
24}
25fn default_stack_gap() -> f32 {
26 12.0
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "snake_case")]
31#[derive(Default)]
32pub enum NotificationVariant {
33 #[default]
34 Info,
35 Success,
36 Warning,
37 Error,
38}
39
40impl NotificationVariant {
41 fn default_color(&self) -> &str {
42 match self {
43 NotificationVariant::Info => "#3B82F6",
44 NotificationVariant::Success => "#22C55E",
45 NotificationVariant::Warning => "#F59E0B",
46 NotificationVariant::Error => "#EF4444",
47 }
48 }
49}
50
51#[derive(Debug, Serialize, Deserialize, JsonSchema)]
52pub struct Notification {
53 pub title: String,
54 #[serde(default)]
55 pub message: Option<String>,
56 #[serde(default)]
57 pub icon: Option<String>,
58 #[serde(default)]
59 pub variant: NotificationVariant,
60 #[serde(default = "default_width")]
61 pub width: f32,
62 #[serde(default = "default_slide_in_at")]
63 pub slide_in_at: f64,
64 #[serde(default)]
65 pub slide_out_at: Option<f64>,
66 #[serde(default = "default_slide_duration")]
67 pub slide_duration: f64,
68 #[serde(default)]
69 pub accent_color: Option<String>,
70 #[serde(default)]
73 pub push_at: Vec<f64>,
74 #[serde(default = "default_stack_gap")]
76 pub stack_gap: f32,
77 #[serde(default)]
80 pub wait_for_push: bool,
81 #[serde(flatten)]
82 pub timing: TimingConfig,
83 #[serde(default)]
84 pub style: CssStyle,
85 #[serde(default)]
86 pub timeline: Vec<TimelineStep>,
87 #[serde(default)]
88 pub stagger: Option<f32>,
89}
90
91rustmotion_core::impl_traits!(Notification {
92 Animatable => animation,
93 Timed => timing,
94 Styled => style,
95});
96
97impl Notification {
98 fn resolved_accent_color(&self) -> &str {
99 self.accent_color
100 .as_deref()
101 .unwrap_or_else(|| self.variant.default_color())
102 }
103
104 fn title_font_size(&self, ctx: &PaintCtx) -> f32 {
109 self.style.font_size_px_ctx(
110 &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
111 16.0,
112 )
113 }
114
115 fn message_font_size(&self, ctx: &PaintCtx) -> f32 {
116 self.title_font_size(ctx) * 0.85
117 }
118
119 fn make_font(&self, bold: bool, size: f32) -> Option<skia_safe::Font> {
120 let font_style = if bold {
121 skia_safe::FontStyle::bold()
122 } else {
123 skia_safe::FontStyle::normal()
124 };
125 let family = self.style.font_family.as_deref().unwrap_or("Inter");
126 let typeface = typeface_with_fallback(family, font_style).ok()?;
127 Some(skia_safe::Font::from_typeface(typeface, size))
128 }
129
130 fn compute_opacity(&self, time: f64) -> f32 {
131 let effective_start = if self.wait_for_push {
133 self.slide_in_at + self.slide_duration
134 } else {
135 self.slide_in_at
136 };
137
138 if time < effective_start {
140 return 0.0;
141 }
142
143 let fade_in_end = effective_start + self.slide_duration;
145 if time < fade_in_end {
146 let t = ((time - effective_start) / self.slide_duration) as f32;
147 return (t * t * (3.0 - 2.0 * t)).clamp(0.0, 1.0); }
149
150 if let Some(slide_out_at) = self.slide_out_at {
152 if time >= slide_out_at {
153 let fade_out_end = slide_out_at + self.slide_duration;
154 if time >= fade_out_end {
155 return 0.0;
156 }
157 let t = ((time - slide_out_at) / self.slide_duration) as f32;
158 return (1.0 - t * t * (3.0 - 2.0 * t)).clamp(0.0, 1.0);
159 }
160 }
161
162 1.0
164 }
165
166 fn render_icon_svg(
167 &self,
168 canvas: &Canvas,
169 icon_id: &str,
170 color: &str,
171 x: f32,
172 y: f32,
173 size: f32,
174 ) -> Result<()> {
175 let icon_w = size.round() as u32;
176 let icon_h = size.round() as u32;
177 let cache_key = format!("icon:{}:{}:{}x{}", icon_id, color, icon_w, icon_h);
178
179 let cache = asset_cache();
180 let img = if let Some(cached) = cache.get(&cache_key) {
181 cached.clone()
182 } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) {
183 let opt = usvg::Options::default();
184 if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
185 let svg_size = tree.size();
186 if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) {
187 let sx = icon_w as f32 / svg_size.width();
188 let sy = icon_h as f32 / svg_size.height();
189 resvg::render(
190 &tree,
191 tiny_skia::Transform::from_scale(sx, sy),
192 &mut pixmap.as_mut(),
193 );
194 let img_data = skia_safe::Data::new_copy(pixmap.data());
195 let info = ImageInfo::new(
196 (icon_w as i32, icon_h as i32),
197 ColorType::RGBA8888,
198 skia_safe::AlphaType::Premul,
199 None,
200 );
201 if let Some(decoded) =
202 skia_safe::images::raster_from_data(&info, img_data, icon_w as usize * 4)
203 {
204 cache.insert(cache_key, decoded.clone());
205 decoded
206 } else {
207 return Ok(());
208 }
209 } else {
210 return Ok(());
211 }
212 } else {
213 return Ok(());
214 }
215 } else {
216 return Ok(());
217 };
218
219 let dst = Rect::from_xywh(x, y, size, size);
220 canvas.draw_image_rect(img, None, dst, &Paint::default());
221 Ok(())
222 }
223}
224
225impl Notification {
226 fn paint(
227 &self,
228 canvas: &Canvas,
229 layout_w: f32,
230 layout_h: f32,
231 time: f64,
232 ctx: &PaintCtx,
233 ) -> Result<()> {
234 let w = layout_w;
235 let h = layout_h;
236 let opacity = self.compute_opacity(time);
237
238 if opacity <= 0.0 {
239 return Ok(());
240 }
241
242 let Some(title_font) = self.make_font(true, self.title_font_size(ctx)) else {
245 return Ok(());
246 };
247
248 let slot_size = h + self.stack_gap;
251 let mut stack_y = 0.0_f32;
252 let transition_dur = self.slide_duration;
253 for &push_time in &self.push_at {
254 if time >= push_time {
255 let t = ((time - push_time) / transition_dur).clamp(0.0, 1.0) as f32;
256 let eased = t * t * (3.0 - 2.0 * t); stack_y += slot_size * eased;
258 }
259 }
260
261 canvas.save();
262 if stack_y > 0.0 {
263 canvas.translate((0.0, stack_y));
264 }
265 if opacity < 1.0 {
266 let mut layer_paint = Paint::default();
267 layer_paint.set_alpha_f(opacity);
268 canvas.save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&layer_paint));
269 }
270
271 let bg_color = self.style.background_color_str().unwrap_or("#1E293B");
272 let radius = self.style.border_radius_px_or(12.0);
273 let accent_color = self.resolved_accent_color();
274 let accent_width = 4.0;
275
276 let bg_rect = Rect::from_xywh(0.0, 0.0, w, h);
278 let bg_rrect = RRect::new_rect_xy(bg_rect, radius, radius);
279 let mut bg_paint = paint_from_hex(bg_color);
280 bg_paint.set_style(PaintStyle::Fill);
281 bg_paint.set_anti_alias(true);
282 canvas.draw_rrect(bg_rrect, &bg_paint);
283
284 let accent_rect = Rect::from_xywh(0.0, 0.0, accent_width, h);
286 let accent_rrect = RRect::new_rect_radii(
287 accent_rect,
288 &[
289 (radius, radius).into(),
290 (0.0, 0.0).into(),
291 (0.0, 0.0).into(),
292 (radius, radius).into(),
293 ],
294 );
295 let mut accent_paint = paint_from_hex(accent_color);
296 accent_paint.set_style(PaintStyle::Fill);
297 accent_paint.set_anti_alias(true);
298 canvas.draw_rrect(accent_rrect, &accent_paint);
299
300 let h_pad = 16.0;
302 let v_pad = 16.0;
303 let icon_size = self.title_font_size(ctx) * 1.5;
304 let mut content_x = accent_width + h_pad;
305
306 if let Some(icon_id) = &self.icon {
308 let icon_y = (h - icon_size) / 2.0;
309 self.render_icon_svg(canvas, icon_id, accent_color, content_x, icon_y, icon_size)?;
310 content_x += icon_size + 12.0;
311 }
312
313 let title_fs = self.title_font_size(ctx);
315 let emoji_font_title =
316 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, title_fs));
317 let title_color = self.style.color_str_or("#FFFFFF");
318 let mut title_paint = paint_from_hex(title_color);
319 title_paint.set_anti_alias(true);
320
321 let (_, title_metrics) = title_font.metrics();
322 let title_y = v_pad + (-title_metrics.ascent);
323
324 draw_text_with_fallback(
325 canvas,
326 &self.title,
327 &title_font,
328 &emoji_font_title,
329 0.0,
330 content_x,
331 title_y,
332 &title_paint,
333 );
334
335 if let Some(message) = &self.message {
337 let msg_fs = self.message_font_size(ctx);
338 if let Some(msg_font) = self.make_font(false, msg_fs) {
339 let emoji_font_msg =
340 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, msg_fs));
341 let mut msg_paint = paint_from_hex("#9CA3AF");
342 msg_paint.set_anti_alias(true);
343
344 let (_, msg_metrics) = msg_font.metrics();
345 let msg_y = title_y + 4.0 + title_fs * 0.3 + (-msg_metrics.ascent);
346
347 draw_text_with_fallback(
348 canvas,
349 message,
350 &msg_font,
351 &emoji_font_msg,
352 0.0,
353 content_x,
354 msg_y,
355 &msg_paint,
356 );
357 }
358 }
359
360 if opacity < 1.0 {
361 canvas.restore();
362 }
363 canvas.restore();
364 Ok(())
365 }
366}
367
368impl Painter for Notification {
369 fn paint_content(
370 &self,
371 canvas: &Canvas,
372 layout: &BoxLayout,
373 _props: &AnimatedProperties,
374 ctx: &PaintCtx,
375 ) {
376 let _ = self.paint(canvas, layout.width, layout.height, ctx.time, ctx);
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use rustmotion_core::css::CssStyle;
384 use rustmotion_core::css::Length;
385
386 fn test_ctx() -> PaintCtx {
387 PaintCtx {
388 time: 1.0,
389 scenario_time: 1.0,
390 scene_duration: 2.0,
391 frame_index: 30,
392 fps: 30,
393 video_width: 400,
394 video_height: 200,
395 stagger_offset: 0.0,
396 }
397 }
398
399 #[test]
402 fn rem_font_size_paints_visible_ink() {
403 let notification = Notification {
406 title: "Hello".to_string(),
407 message: None,
408 icon: None,
409 variant: NotificationVariant::Info,
410 width: default_width(),
411 slide_in_at: 0.0,
412 slide_out_at: None,
413 slide_duration: default_slide_duration(),
414 accent_color: None,
415 push_at: Vec::new(),
416 stack_gap: default_stack_gap(),
417 wait_for_push: false,
418 timing: Default::default(),
419 style: CssStyle {
420 font_size: Some(Length::String("2rem".into())),
421 ..Default::default()
422 },
423 timeline: Vec::new(),
424 stagger: None,
425 };
426 const W: i32 = 400;
427 const H: i32 = 200;
428 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
429 {
430 let canvas = surface.canvas();
431 notification
432 .paint(canvas, 360.0, 100.0, 1.0, &test_ctx())
433 .expect("paint succeeds");
434 }
435 let snapshot = surface.image_snapshot();
436 let info = skia_safe::ImageInfo::new(
437 (W, H),
438 skia_safe::ColorType::RGBA8888,
439 skia_safe::AlphaType::Premul,
440 None,
441 );
442 let mut buf = vec![0u8; (W * H * 4) as usize];
443 let ok = snapshot.read_pixels(
444 &info,
445 &mut buf,
446 (W * 4) as usize,
447 skia_safe::IPoint::new(0, 0),
448 skia_safe::image::CachingHint::Disallow,
449 );
450 assert!(ok, "pixel read should succeed");
451 let text_ink = buf
454 .chunks_exact(4)
455 .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
456 .count();
457 assert!(
458 text_ink > 10,
459 "notification at font-size: 2rem must paint visible text, got {text_ink} pixels"
460 );
461 }
462}