Skip to main content

rustmotion_components/
notification.rs

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    /// Timestamps at which this notification gets pushed down one slot
71    /// (i.e. when another notification appears above it).
72    #[serde(default)]
73    pub push_at: Vec<f64>,
74    /// Gap between stacked notifications in pixels (default 12).
75    #[serde(default = "default_stack_gap")]
76    pub stack_gap: f32,
77    /// Delay fade-in until after other notifications finish their push animation.
78    /// Set to true when this notification triggers a push_at on others.
79    #[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    /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/
105    /// `vh` now resolve instead of silently dropping to 0px — lot B, wave
106    /// S). `em`/`%` on `font-size` itself remain approximate — see
107    /// `crate::intrinsic::font_size_ctx`'s doc comment.
108    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        // Effective start: if wait_for_push, delay by slide_duration so push animations finish first
132        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        // Before fade in
139        if time < effective_start {
140            return 0.0;
141        }
142
143        // During fade in
144        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); // smoothstep
148        }
149
150        // Check fade out
151        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        // Fully visible
163        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        // Resolve the title font before any canvas.save() so an early return
243        // on font failure keeps save/restore balanced.
244        let Some(title_font) = self.make_font(true, self.title_font_size(ctx)) else {
245            return Ok(());
246        };
247
248        // Stack offset: count how many push_at timestamps have passed,
249        // each one shifts this notification down by one slot with animation.
250        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); // smoothstep
257                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        // Background rounded rect
277        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        // Left accent stripe
285        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        // Content area
301        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        // Icon
307        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        // Title
314        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        // Message
336        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    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
400
401    #[test]
402    fn rem_font_size_paints_visible_ink() {
403        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
404        // context-free `font_size_px_or`.
405        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        // Title text is white (#FFFFFF default) on a dark #1E293B card —
452        // probe for near-white ink specifically.
453        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}