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 #[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
134pub(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 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 fn line_prefix(line_type: &TerminalLineType) -> &'static str {
167 match line_type {
168 TerminalLineType::Prompt => "$ ",
169 TerminalLineType::Command | TerminalLineType::Output => "",
170 }
171 }
172
173 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 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 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 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 let Some(font) = self.make_font(font_size) else {
261 return;
262 };
263
264 canvas.save();
267 canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true);
268
269 let mut y_offset = 0.0;
270
271 if self.show_chrome {
273 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 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 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 let (visible_lines, partial_chars, last_line_opacity) = self.compute_reveal(time);
316
317 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 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 let (draw_prefix, draw_text) = if is_last_visible {
369 if let Some(char_limit) = partial_chars {
370 let prefix_len = prefix.len();
372 if char_limit <= prefix_len {
373 let partial: String = prefix.chars().take(char_limit).collect();
375 (partial, String::new())
376 } else {
377 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 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 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 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(); canvas.restore(); }
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 #[test]
479 fn rem_font_size_paints_visible_ink() {
480 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 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}