Skip to main content

rustmotion_components/
terminal.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, RRect, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::{ease, AnimatedProperties};
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    draw_text_with_fallback, emoji_typeface, paint_from_hex, resolve_custom_typeface,
10    typeface_with_fallback,
11};
12use rustmotion_core::schema::{CodeblockReveal, RevealMode, 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 TerminalLineType {
19    Prompt,
20    Command,
21    #[default]
22    Output,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26pub struct TerminalLine {
27    pub text: String,
28    #[serde(default)]
29    pub line_type: TerminalLineType,
30    #[serde(default)]
31    pub color: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "snake_case")]
36#[derive(Default)]
37pub enum TerminalTheme {
38    #[default]
39    Dark,
40    Light,
41}
42
43impl TerminalTheme {
44    fn bg(&self) -> &str {
45        match self {
46            TerminalTheme::Dark => "#1E1E1E",
47            TerminalTheme::Light => "#F5F5F5",
48        }
49    }
50
51    fn chrome_bg(&self) -> &str {
52        match self {
53            TerminalTheme::Dark => "#2D2D2D",
54            TerminalTheme::Light => "#E5E5E5",
55        }
56    }
57
58    fn prompt_color(&self) -> &str {
59        match self {
60            TerminalTheme::Dark => "#22C55E",
61            TerminalTheme::Light => "#16A34A",
62        }
63    }
64
65    fn command_color(&self) -> &str {
66        match self {
67            TerminalTheme::Dark => "#FFFFFF",
68            TerminalTheme::Light => "#000000",
69        }
70    }
71
72    fn output_color(&self) -> &str {
73        match self {
74            TerminalTheme::Dark => "#A0A0A0",
75            TerminalTheme::Light => "#555555",
76        }
77    }
78
79    fn title_color(&self) -> &str {
80        match self {
81            TerminalTheme::Dark => "#808080",
82            TerminalTheme::Light => "#666666",
83        }
84    }
85}
86
87#[derive(Debug, Serialize, Deserialize, JsonSchema)]
88pub struct Terminal {
89    pub lines: Vec<TerminalLine>,
90    #[serde(default)]
91    pub theme: TerminalTheme,
92    #[serde(default)]
93    pub title: Option<String>,
94    #[serde(default = "default_show_chrome")]
95    pub show_chrome: bool,
96    #[serde(default)]
97    pub reveal: Option<CodeblockReveal>,
98    /// When rendered content overflows the box vertically, scroll up so the
99    /// last revealed line stays visible. Default: `true`. Set to `false` to
100    /// require all lines fit — the geometry validator will fail otherwise.
101    /// Font size is never reduced.
102    #[serde(default = "default_auto_scroll")]
103    pub auto_scroll: bool,
104    #[serde(flatten)]
105    pub timing: TimingConfig,
106    #[serde(default)]
107    pub style: CssStyle,
108    #[serde(default)]
109    pub timeline: Vec<TimelineStep>,
110    #[serde(default)]
111    pub stagger: Option<f32>,
112}
113
114fn default_show_chrome() -> bool {
115    true
116}
117
118fn default_auto_scroll() -> bool {
119    true
120}
121
122rustmotion_core::impl_traits!(Terminal {
123    Animatable => animation,
124    Timed => timing,
125    Styled => style,
126});
127
128pub const CORNER_RADIUS: f32 = 10.0;
129pub(crate) const CHROME_HEIGHT: f32 = 36.0;
130pub(crate) const FONT_SIZE: f32 = 14.0;
131pub(crate) const LINE_HEIGHT: f32 = 22.0;
132pub(crate) const PADDING: f32 = 16.0;
133
134/// The typeface both the painter and the intrinsic measurement resolve.
135///
136/// These must never diverge. The measurement reserves the box the painter then
137/// fills, so a different face on either side produces text that overflows a box
138/// the geometry validator has already declared safe — the exact failure mode the
139/// audit found across the text components. Both sides used to hardcode
140/// `"SF Mono"` independently, which agreed only by coincidence and ignored an
141/// explicit `font-family` outright, custom or not.
142pub(crate) fn resolve_typeface(style: &CssStyle) -> Option<skia_safe::Typeface> {
143    let font_style = skia_safe::FontStyle::normal();
144    let family = style.font_family_or("SF Mono");
145    resolve_custom_typeface(family, font_style)
146        .or_else(|| typeface_with_fallback(family, font_style).ok())
147}
148
149impl Terminal {
150    /// `font_size` is resolved once by the caller (`paint`, against a real
151    /// `LengthContext`) and threaded through here and [`Self::line_height`]
152    /// instead of each independently re-deriving it via the context-free
153    /// `font_size_px_or` — four separate call sites used to do exactly that,
154    /// which is how a relative unit could silently diverge between them
155    /// (lot B, wave S).
156    fn make_font(&self, font_size: f32) -> Option<skia_safe::Font> {
157        let typeface = resolve_typeface(&self.style)?;
158        Some(skia_safe::Font::from_typeface(typeface, font_size))
159    }
160
161    fn line_height(&self, font_size: f32) -> f32 {
162        (font_size * LINE_HEIGHT / FONT_SIZE).ceil()
163    }
164
165    /// Get the prefix string for a line type.
166    fn line_prefix(line_type: &TerminalLineType) -> &'static str {
167        match line_type {
168            TerminalLineType::Prompt => "$ ",
169            TerminalLineType::Command | TerminalLineType::Output => "",
170        }
171    }
172
173    /// Compute reveal visibility: (visible_lines, partial_chars_on_last_line, last_line_opacity)
174    fn compute_reveal(&self, time: f64) -> (usize, Option<usize>, f32) {
175        let total_lines = self.lines.len();
176        if total_lines == 0 {
177            return (0, None, 1.0);
178        }
179
180        let reveal = match &self.reveal {
181            None => return (total_lines, None, 1.0),
182            Some(r) => r,
183        };
184
185        if time < reveal.start {
186            return (0, None, 1.0);
187        }
188
189        let raw_progress = ((time - reveal.start) / reveal.duration).clamp(0.0, 1.0);
190        let progress = ease(raw_progress, &reveal.easing);
191
192        match reveal.mode {
193            RevealMode::Typewriter => {
194                // Count total characters including prefixes
195                let total_chars: usize = self
196                    .lines
197                    .iter()
198                    .map(|l| Self::line_prefix(&l.line_type).len() + l.text.len())
199                    .sum();
200
201                let visible_chars = (total_chars as f64 * progress).round() as usize;
202                let mut chars_remaining = visible_chars;
203                let mut visible_lines = 0;
204                let mut partial_chars = None;
205
206                for line in &self.lines {
207                    let line_chars = Self::line_prefix(&line.line_type).len() + line.text.len();
208                    if chars_remaining >= line_chars {
209                        chars_remaining -= line_chars;
210                        visible_lines += 1;
211                    } else {
212                        visible_lines += 1;
213                        partial_chars = Some(chars_remaining);
214                        break;
215                    }
216                }
217
218                (visible_lines, partial_chars, 1.0)
219            }
220            RevealMode::LineByLine => {
221                let visible_f = total_lines as f64 * progress;
222                let full_lines = visible_f.floor() as usize;
223                let fractional = (visible_f - full_lines as f64) as f32;
224
225                if full_lines >= total_lines {
226                    (total_lines, None, 1.0)
227                } else {
228                    (full_lines + 1, None, fractional.max(0.01))
229                }
230            }
231        }
232    }
233}
234
235impl Terminal {
236    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) {
237        let time = ctx.time;
238        let w = layout_w;
239        let h = layout_h;
240
241        // Resolved once, against the real per-frame viewport (`rem`/`vw`/
242        // `vh` on `font-size` now resolve instead of silently dropping to
243        // 0px — lot B, wave S) and threaded through every call below that
244        // used to independently re-derive it.
245        let font_size = self.style.font_size_px_ctx(
246            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
247            FONT_SIZE,
248        );
249
250        // Background
251        let bg_rect = Rect::from_xywh(0.0, 0.0, w, h);
252        let bg_rrect = RRect::new_rect_xy(bg_rect, CORNER_RADIUS, CORNER_RADIUS);
253        let mut bg_paint = paint_from_hex(self.theme.bg());
254        bg_paint.set_style(PaintStyle::Fill);
255        bg_paint.set_anti_alias(true);
256        canvas.draw_rrect(bg_rrect, &bg_paint);
257
258        // Resolve the terminal font up front — bail before any canvas.save()
259        // so save/restore stays balanced if no font is available.
260        let Some(font) = self.make_font(font_size) else {
261            return;
262        };
263
264        // Always clip content to the rounded box so scrolled lines fade
265        // cleanly at the edges and never escape the device viewport.
266        canvas.save();
267        canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true);
268
269        let mut y_offset = 0.0;
270
271        // Chrome (title bar)
272        if self.show_chrome {
273            // Chrome background
274            let chrome_rect = Rect::from_xywh(0.0, 0.0, w, CHROME_HEIGHT);
275            canvas.save();
276            canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true);
277            let mut chrome_paint = paint_from_hex(self.theme.chrome_bg());
278            chrome_paint.set_style(PaintStyle::Fill);
279            canvas.draw_rect(chrome_rect, &chrome_paint);
280            canvas.restore();
281
282            // Traffic light dots
283            let dot_colors = ["#FF5F57", "#FEBC2E", "#28C840"];
284            let dot_y = CHROME_HEIGHT / 2.0;
285            for (i, color) in dot_colors.iter().enumerate() {
286                let dot_x = 14.0 + i as f32 * 20.0;
287                let mut dot_paint = paint_from_hex(color);
288                dot_paint.set_style(PaintStyle::Fill);
289                dot_paint.set_anti_alias(true);
290                canvas.draw_circle((dot_x, dot_y), 6.0, &dot_paint);
291            }
292
293            // Title
294            if let Some(title) = &self.title {
295                let emoji_font =
296                    emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
297                let mut title_paint = paint_from_hex(self.theme.title_color());
298                title_paint.set_anti_alias(true);
299                let title_w = rustmotion_core::engine::renderer::measure_text_with_fallback(
300                    title,
301                    &font,
302                    &emoji_font,
303                    0.0,
304                );
305                let x = (w - title_w) / 2.0;
306                let (_, metrics) = font.metrics();
307                let y = CHROME_HEIGHT / 2.0 + (-metrics.ascent) / 2.0;
308                draw_text_with_fallback(canvas, title, &font, &emoji_font, 0.0, x, y, &title_paint);
309            }
310
311            y_offset = CHROME_HEIGHT;
312        }
313
314        // Compute reveal visibility
315        let (visible_lines, partial_chars, last_line_opacity) = self.compute_reveal(time);
316
317        // Lines
318        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
319        let (_, metrics) = font.metrics();
320        let ascent = -metrics.ascent;
321
322        y_offset += PADDING;
323
324        // auto_scroll: when the rendered content is taller than the box,
325        // translate the lines upward so the latest revealed line stays in
326        // view. We open a nested clip below the chrome so scrolled lines
327        // never bleed onto the title bar.
328        let chrome_h = if self.show_chrome { CHROME_HEIGHT } else { 0.0 };
329        canvas.save();
330        canvas.clip_rect(
331            Rect::from_xywh(0.0, chrome_h, w, h - chrome_h),
332            skia_safe::ClipOp::Intersect,
333            true,
334        );
335        if self.auto_scroll {
336            let line_h = self.line_height(font_size);
337            let content_h = visible_lines as f32 * line_h + PADDING * 2.0 + chrome_h;
338            let overflow = content_h - h;
339            if overflow > 0.0 {
340                canvas.translate((0.0, -overflow));
341            }
342        }
343
344        for (i, line) in self.lines.iter().enumerate() {
345            if i >= visible_lines {
346                break;
347            }
348
349            let is_last_visible = i == visible_lines - 1;
350            let opacity = if is_last_visible {
351                last_line_opacity
352            } else {
353                1.0
354            };
355
356            let prefix = Self::line_prefix(&line.line_type);
357            let (prefix_color, text_color) = match line.line_type {
358                TerminalLineType::Prompt => (self.theme.prompt_color(), self.theme.prompt_color()),
359                TerminalLineType::Command => ("", self.theme.command_color()),
360                TerminalLineType::Output => ("", self.theme.output_color()),
361            };
362
363            let color = line.color.as_deref().unwrap_or(text_color);
364            let y = y_offset + ascent;
365            let mut x = PADDING;
366
367            // Determine what to draw based on partial_chars for typewriter mode
368            let (draw_prefix, draw_text) = if is_last_visible {
369                if let Some(char_limit) = partial_chars {
370                    // Truncate: prefix first, then text
371                    let prefix_len = prefix.len();
372                    if char_limit <= prefix_len {
373                        // Only partial prefix visible
374                        let partial: String = prefix.chars().take(char_limit).collect();
375                        (partial, String::new())
376                    } else {
377                        // Full prefix + partial text
378                        let text_chars = char_limit - prefix_len;
379                        let partial: String = line.text.chars().take(text_chars).collect();
380                        (prefix.to_string(), partial)
381                    }
382                } else {
383                    (prefix.to_string(), line.text.clone())
384                }
385            } else {
386                (prefix.to_string(), line.text.clone())
387            };
388
389            // Draw prompt prefix
390            if !draw_prefix.is_empty() {
391                let mut prefix_paint = paint_from_hex(prefix_color);
392                prefix_paint.set_anti_alias(true);
393                prefix_paint.set_alpha_f(opacity);
394                let prefix_w = rustmotion_core::engine::renderer::measure_text_with_fallback(
395                    &draw_prefix,
396                    &font,
397                    &emoji_font,
398                    0.0,
399                );
400                draw_text_with_fallback(
401                    canvas,
402                    &draw_prefix,
403                    &font,
404                    &emoji_font,
405                    0.0,
406                    x,
407                    y,
408                    &prefix_paint,
409                );
410                x += prefix_w + 2.0;
411            }
412
413            // Draw text
414            if !draw_text.is_empty() {
415                let mut text_paint = paint_from_hex(color);
416                text_paint.set_anti_alias(true);
417                text_paint.set_alpha_f(opacity);
418                let text_w = rustmotion_core::engine::renderer::measure_text_with_fallback(
419                    &draw_text,
420                    &font,
421                    &emoji_font,
422                    0.0,
423                );
424                draw_text_with_fallback(
425                    canvas,
426                    &draw_text,
427                    &font,
428                    &emoji_font,
429                    0.0,
430                    x,
431                    y,
432                    &text_paint,
433                );
434                x += text_w;
435            }
436
437            // Blinking cursor on the last visible line during typewriter reveal
438            if is_last_visible && self.reveal.is_some() && partial_chars.is_some() {
439                let blink = ((time * 2.0) as i32) % 2 == 0;
440                if blink {
441                    let cursor_w = font_size * 0.55;
442                    let cursor_h = font_size * 1.2;
443                    let cursor_y = y - font_size;
444                    let cursor_rect = Rect::from_xywh(x + 1.0, cursor_y, cursor_w, cursor_h);
445                    let mut cursor_paint = paint_from_hex(self.theme.command_color());
446                    cursor_paint.set_style(PaintStyle::Fill);
447                    cursor_paint.set_anti_alias(true);
448                    canvas.draw_rect(cursor_rect, &cursor_paint);
449                }
450            }
451
452            y_offset += self.line_height(font_size);
453        }
454
455        canvas.restore(); // close inner content clip
456        canvas.restore(); // close outer rrect clip
457    }
458}
459
460impl Painter for Terminal {
461    fn paint_content(
462        &self,
463        canvas: &Canvas,
464        layout: &BoxLayout,
465        _props: &AnimatedProperties,
466        ctx: &PaintCtx,
467    ) {
468        self.paint(canvas, layout.width, layout.height, ctx);
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
477
478    #[test]
479    fn rem_font_size_paints_visible_ink() {
480        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
481        // context-free `font_size_px_or`, at all four call sites that used
482        // to independently re-derive it in `paint`.
483        let terminal = Terminal {
484            lines: vec![TerminalLine {
485                text: "hello world".to_string(),
486                line_type: TerminalLineType::Output,
487                color: None,
488            }],
489            theme: TerminalTheme::default(),
490            title: None,
491            show_chrome: false,
492            reveal: None,
493            auto_scroll: true,
494            timing: Default::default(),
495            style: CssStyle {
496                font_size: Some(rustmotion_core::css::Length::String("2rem".into())),
497                ..Default::default()
498            },
499            timeline: Vec::new(),
500            stagger: None,
501        };
502        const W: i32 = 400;
503        const H: i32 = 200;
504        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
505        let ctx = PaintCtx {
506            time: 0.0,
507            scenario_time: 0.0,
508            scene_duration: 1.0,
509            frame_index: 0,
510            fps: 30,
511            video_width: 400,
512            video_height: 200,
513            stagger_offset: 0.0,
514        };
515        {
516            let canvas = surface.canvas();
517            terminal.paint(canvas, W as f32, H as f32, &ctx);
518        }
519        let snapshot = surface.image_snapshot();
520        let info = skia_safe::ImageInfo::new(
521            (W, H),
522            skia_safe::ColorType::RGBA8888,
523            skia_safe::AlphaType::Premul,
524            None,
525        );
526        let mut buf = vec![0u8; (W * H * 4) as usize];
527        let ok = snapshot.read_pixels(
528            &info,
529            &mut buf,
530            (W * 4) as usize,
531            skia_safe::IPoint::new(0, 0),
532            skia_safe::image::CachingHint::Disallow,
533        );
534        assert!(ok, "pixel read should succeed");
535        // Background always paints (opaque bg_rrect), so probe for text ink
536        // specifically: pixels that are not the dark theme background color
537        // (#1E1E1E) and not fully transparent.
538        let text_ink = buf
539            .chunks_exact(4)
540            .filter(|p| p[3] > 0 && !(p[0] < 40 && p[1] < 40 && p[2] < 40))
541            .count();
542        assert!(
543            text_ink > 20,
544            "terminal at font-size: 2rem must paint visible text ink, got {text_ink} pixels"
545        );
546    }
547}