use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_left};
use crate::motion::EASE_OUT;
pub const DEMO_CODE: &str = r#"// components-generator
export const greet = (name: string) => {
const message = `Hello, ${name}!`;
console.log(message);
};
greet("Remotion");"#;
pub const HOLD_TAIL_FRAMES: usize = 90;
pub const BLINK_CYCLE_FRAMES: f32 = 30.0;
pub const BLINK_FADE_FRAMES: f32 = 2.0;
pub const GUTTER_EM: f32 = 2.5;
pub const GUTTER_PADDING_EM: f32 = 1.0;
pub const LINE_HEIGHT: f32 = 1.5;
pub const CARD_RADIUS: f32 = 12.0;
pub const FONT_WEIGHT: u16 = 400;
pub const LINE_NUMBER_COLOR: &str = "#666";
pub const LINE_NUMBER_OPACITY: f32 = 0.5;
pub const CURSOR_COLOR: &str = "#d4d4d4";
pub const CURSOR: &str = "|";
pub const KEYWORDS: &[&str] = &[
"abstract",
"and",
"any",
"as",
"async",
"await",
"bool",
"break",
"case",
"catch",
"class",
"const",
"continue",
"def",
"default",
"del",
"delete",
"do",
"elif",
"else",
"enum",
"except",
"export",
"extends",
"false",
"finally",
"float",
"for",
"from",
"function",
"global",
"if",
"implements",
"import",
"in",
"instanceof",
"int",
"interface",
"is",
"lambda",
"let",
"new",
"None",
"not",
"null",
"number",
"or",
"package",
"pass",
"private",
"protected",
"public",
"raise",
"return",
"self",
"static",
"str",
"string",
"super",
"switch",
"this",
"throw",
"true",
"try",
"type",
"typeof",
"undefined",
"var",
"void",
"while",
"with",
"yield",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Default,
Keyword,
String,
Comment,
Number,
Function,
}
impl TokenKind {
pub fn color(self) -> &'static str {
match self {
Self::Default => "#d4d4d4",
Self::Keyword => "#569cd6",
Self::String => "#ce9178",
Self::Comment => "#6a9955",
Self::Number => "#b5cea8",
Self::Function => "#dcdcaa",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub text: String,
pub kind: TokenKind,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TypingCodeBlockConfig {
pub code: String,
pub language: String,
pub typing_speed: f32,
pub delay: usize,
pub show_line_numbers: bool,
pub background: String,
pub font_size: Option<f32>,
}
impl Default for TypingCodeBlockConfig {
fn default() -> Self {
Self {
code: DEMO_CODE.to_owned(),
language: "tsx".to_owned(),
typing_speed: 0.3,
delay: 0,
show_line_numbers: true,
background: "#1e1e1e".to_owned(),
font_size: None,
}
}
}
pub fn get_typing_code_block_duration(code_len: usize, typing_speed: f32, delay: usize) -> usize {
let frames = delay as f32 + code_len as f32 * typing_speed;
frames.ceil().max(0.0) as usize
}
pub fn typed_char_count(frame: f32, delay: usize, typing_speed: f32, char_count: usize) -> usize {
if char_count == 0 {
return 0;
}
let typed = ((frame - delay as f32) / typing_speed).floor().max(0.0);
(typed as usize).min(char_count)
}
pub fn typed_slice(code: &str, typed_count: usize) -> &str {
match code.char_indices().nth(typed_count) {
Some((index, _)) => &code[..index],
None => code,
}
}
pub fn cursor_visibility(clock: f32) -> f32 {
let within = clock.max(0.0) % BLINK_CYCLE_FRAMES;
let half = BLINK_CYCLE_FRAMES / 2.0;
let fade_in = EASE_OUT.ease((within / BLINK_FADE_FRAMES).min(1.0));
let fade_out = EASE_OUT.ease(((half - within) / BLINK_FADE_FRAMES).min(1.0));
fade_in.min(fade_out).clamp(0.0, 1.0)
}
fn is_word_char(character: char) -> bool {
character.is_alphanumeric() || character == '_' || character == '$'
}
fn starts_token(character: char) -> bool {
is_word_char(character) || matches!(character, '"' | '\'' | '`' | '/' | '\n')
}
fn is_call(chars: &[char], mut index: usize) -> bool {
while chars.get(index).is_some_and(|c| c.is_whitespace()) {
index += 1;
}
chars.get(index) == Some(&'(')
}
fn push_token(lines: &mut [Vec<Token>], token: Token) {
if token.text.is_empty() {
return;
}
if let Some(line) = lines.last_mut() {
line.push(token);
}
}
pub fn tokenize(source: &str) -> Vec<Vec<Token>> {
let chars: Vec<char> = source.chars().collect();
let mut lines: Vec<Vec<Token>> = vec![Vec::new()];
let mut index = 0;
let mut in_block_comment = false;
while index < chars.len() {
let current = chars[index];
if current == '\n' {
lines.push(Vec::new());
index += 1;
continue;
}
if in_block_comment {
let start = index;
let mut closed = false;
while index < chars.len() && chars[index] != '\n' {
if chars[index] == '*' && chars.get(index + 1) == Some(&'/') {
index += 2;
closed = true;
break;
}
index += 1;
}
let text: String = chars[start..index].iter().collect();
push_token(
&mut lines,
Token {
text,
kind: TokenKind::Comment,
},
);
in_block_comment = !closed;
continue;
}
if current == '/' && chars.get(index + 1) == Some(&'/') {
let start = index;
while index < chars.len() && chars[index] != '\n' {
index += 1;
}
let text: String = chars[start..index].iter().collect();
push_token(
&mut lines,
Token {
text,
kind: TokenKind::Comment,
},
);
continue;
}
if current == '/' && chars.get(index + 1) == Some(&'*') {
in_block_comment = true;
continue;
}
if matches!(current, '"' | '\'' | '`') {
let quote = current;
let start = index;
index += 1;
while index < chars.len() && chars[index] != '\n' {
if chars[index] == '\\' {
if chars.get(index + 1).is_some_and(|next| *next != '\n') {
index += 2;
} else {
index += 1;
}
continue;
}
if chars[index] == quote {
index += 1;
break;
}
index += 1;
}
let text: String = chars[start..index].iter().collect();
push_token(
&mut lines,
Token {
text,
kind: TokenKind::String,
},
);
continue;
}
if current.is_ascii_digit() {
let start = index;
let radix = current == '0'
&& chars
.get(index + 1)
.is_some_and(|c| matches!(*c, 'x' | 'X' | 'b' | 'B' | 'o' | 'O'));
if radix {
index += 2;
while index < chars.len()
&& (chars[index].is_ascii_alphanumeric() || chars[index] == '_')
{
index += 1;
}
} else {
while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '_')
{
index += 1;
}
if chars.get(index) == Some(&'.')
&& chars.get(index + 1).is_some_and(char::is_ascii_digit)
{
index += 1;
while index < chars.len()
&& (chars[index].is_ascii_digit() || chars[index] == '_')
{
index += 1;
}
}
if chars.get(index).is_some_and(|c| matches!(*c, 'e' | 'E')) {
let mut probe = index + 1;
if chars.get(probe).is_some_and(|c| matches!(*c, '+' | '-')) {
probe += 1;
}
if chars.get(probe).is_some_and(char::is_ascii_digit) {
index = probe;
while index < chars.len()
&& (chars[index].is_ascii_digit() || chars[index] == '_')
{
index += 1;
}
}
}
}
let text: String = chars[start..index].iter().collect();
push_token(
&mut lines,
Token {
text,
kind: TokenKind::Number,
},
);
continue;
}
if current.is_alphabetic() || current == '_' || current == '$' {
let start = index;
while index < chars.len() && is_word_char(chars[index]) {
index += 1;
}
let text: String = chars[start..index].iter().collect();
let kind = if KEYWORDS.contains(&text.as_str()) {
TokenKind::Keyword
} else if is_call(&chars, index) {
TokenKind::Function
} else {
TokenKind::Default
};
push_token(&mut lines, Token { text, kind });
continue;
}
let mut end = index + 1;
while end < chars.len() && !starts_token(chars[end]) {
end += 1;
}
let text: String = chars[index..end].iter().collect();
push_token(
&mut lines,
Token {
text,
kind: TokenKind::Default,
},
);
index = end;
}
lines
}
pub fn prefix_lines(lines: &[Vec<Token>], typed_count: usize) -> Vec<Vec<Token>> {
let mut out: Vec<Vec<Token>> = Vec::new();
let mut remaining = typed_count;
for (index, line) in lines.iter().enumerate() {
let length: usize = line.iter().map(|token| token.text.chars().count()).sum();
if remaining >= length {
out.push(line.clone());
remaining -= length;
if index + 1 < lines.len() {
if remaining == 0 {
break;
}
remaining -= 1;
}
continue;
}
out.push(truncate_line(line, remaining));
break;
}
if out.is_empty() {
out.push(Vec::new());
}
out
}
fn truncate_line(line: &[Token], count: usize) -> Vec<Token> {
let mut out = Vec::new();
let mut remaining = count;
for token in line {
if remaining == 0 {
break;
}
let length = token.text.chars().count();
if length <= remaining {
out.push(token.clone());
remaining -= length;
} else {
out.push(Token {
text: token.text.chars().take(remaining).collect(),
kind: token.kind,
});
remaining = 0;
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Chrome {
font_size: f32,
x: f32,
y: f32,
width: f32,
height: f32,
text_x: f32,
first_baseline: f32,
line_step: f32,
}
#[derive(Debug, Clone, Default)]
pub struct TypingCodeBlock {
pub config: TypingCodeBlockConfig,
tokens: OnceLock<Vec<Vec<Token>>>,
chrome: OnceLock<Chrome>,
}
impl TypingCodeBlock {
pub fn new(config: TypingCodeBlockConfig) -> Self {
Self {
config,
tokens: OnceLock::new(),
chrome: OnceLock::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "typing-code-block",
name: "Typing Code Block",
summary: "A highlighted editor window types a code sample out character by character.",
feature: "typing-code-block",
}
}
fn token_lines(&self) -> &Vec<Vec<Token>> {
self.tokens.get_or_init(|| tokenize(&self.config.code))
}
fn chrome(&self, ctx: &RenderCtx<'_>, family: &str) -> Chrome {
*self.chrome.get_or_init(|| {
let font_size = self
.config
.font_size
.unwrap_or_else(|| ctx.width() * 0.02)
.max(1.0);
let lines = self.token_lines();
let line_step = font_size * LINE_HEIGHT;
let padding = font_size;
let gutter = if self.config.show_line_numbers {
GUTTER_EM * font_size
} else {
0.0
};
let mut code_width: f32 = 0.0;
for line in lines {
let width: f32 = line
.iter()
.map(|token| ctx.text_width(family, font_size, &token.text))
.sum();
code_width = code_width.max(width);
}
let width = code_width + gutter + padding * 2.0;
let height = line_step * lines.len().max(1) as f32 + padding * 2.0;
let x = (ctx.width() - width) / 2.0;
let y = (ctx.height() - height) / 2.0;
Chrome {
font_size,
x,
y,
width,
height,
text_x: x + padding + gutter,
first_baseline: y + padding + ctx.baseline_offset(family, font_size, line_step),
line_step,
}
})
}
}
impl Component for TypingCodeBlock {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_typing_code_block_duration(
self.config.code.chars().count(),
self.config.typing_speed,
self.config.delay,
) + HOLD_TAIL_FRAMES
}
fn background(&self) -> Option<&'static str> {
Some("#0d1117")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let family = ctx.mono();
let chrome = self.chrome(ctx, &family);
let frame = ctx.frame();
let char_count = self.config.code.chars().count();
let typed = typed_char_count(
frame,
self.config.delay,
self.config.typing_speed,
char_count,
);
let sliced = typed_slice(&self.config.code, typed);
let lines = prefix_lines(self.token_lines(), typed);
let clock = (frame - self.config.delay as f32).max(0.0);
let cursor_opacity = cursor_visibility(clock);
let mut nodes: Vec<Svgr<'_>> = Vec::new();
nodes.push(fframes::svgr!(
<rect x={chrome.x} y={chrome.y} width={chrome.width} height={chrome.height}
rx={CARD_RADIUS} fill={self.config.background.clone()} />
));
let number_x = chrome.text_x - GUTTER_PADDING_EM * chrome.font_size;
for (index, line) in lines.iter().enumerate() {
let y = chrome.first_baseline + index as f32 * chrome.line_step;
if self.config.show_line_numbers {
let number = (index + 1).to_string();
nodes.push(fframes::svgr!(
<text x={number_x} y={y} font-family={family.clone()}
font-size={chrome.font_size} fill={LINE_NUMBER_COLOR}
opacity={LINE_NUMBER_OPACITY} text-anchor="end">{number}</text>
));
}
let mut x = chrome.text_x;
for token in line {
if !token.text.chars().all(char::is_whitespace) {
nodes.push(text_node_left(
token.text.clone(),
x,
y,
family.clone(),
chrome.font_size,
FONT_WEIGHT,
0.0,
token.kind.color().to_owned(),
1.0,
));
}
x += ctx.text_width(&family, chrome.font_size, &token.text);
}
}
let last_index = lines.len().saturating_sub(1);
let last_line = sliced.rsplit('\n').next().unwrap_or_default();
let cursor_x = chrome.text_x + ctx.text_width(&family, chrome.font_size, last_line);
let cursor_y = chrome.first_baseline + last_index as f32 * chrome.line_step;
nodes.push(text_node_left(
CURSOR.to_owned(),
cursor_x,
cursor_y,
family,
chrome.font_size,
FONT_WEIGHT,
0.0,
CURSOR_COLOR.to_owned(),
cursor_opacity,
));
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(line: &[Token]) -> Vec<(TokenKind, &str)> {
line.iter()
.map(|token| (token.kind, token.text.as_str()))
.collect()
}
#[test]
fn demo_code_is_the_source_sample() {
assert_eq!(DEMO_CODE.lines().count(), 7);
assert!(DEMO_CODE.starts_with("// components-generator\n"));
assert!(DEMO_CODE.ends_with("greet(\"Remotion\");"));
assert_eq!(DEMO_CODE.chars().count(), 148);
}
#[test]
fn duration_matches_the_source_formula() {
assert_eq!(get_typing_code_block_duration(10, 0.5, 4), 9);
assert_eq!(get_typing_code_block_duration(10, 0.0, 3), 3);
assert_eq!(get_typing_code_block_duration(0, 0.3, 0), 0);
assert_eq!(get_typing_code_block_duration(148, 0.3, 0), 45);
assert_eq!(get_typing_code_block_duration(10, 0.5, 7), 12);
assert_eq!(get_typing_code_block_duration(30, 0.3, 0), 9);
assert_eq!(get_typing_code_block_duration(10, -5.0, 0), 0);
assert_eq!(
get_typing_code_block_duration(10, f32::INFINITY, 0),
usize::MAX
);
}
#[test]
fn typed_char_count_matches_the_source_formula() {
assert_eq!(typed_char_count(0.0, 0, 0.5, 100), 0);
assert_eq!(typed_char_count(4.9, 0, 0.5, 100), 9);
assert_eq!(typed_char_count(5.0, 0, 0.5, 100), 10);
assert_eq!(typed_char_count(1_000.0, 0, 0.5, 100), 100);
assert_eq!(typed_char_count(5.0, 10, 0.5, 100), 0);
assert_eq!(typed_char_count(15.0, 10, 0.5, 100), 10);
assert_eq!(typed_char_count(0.0, 0, 0.3, 148), 0);
assert_eq!(typed_char_count(1.0, 0, 0.3, 148), 3);
assert_eq!(typed_char_count(3.0, 0, 0.3, 148), 10);
assert_eq!(typed_char_count(45.0, 0, 0.3, 148), 148);
assert_eq!(typed_char_count(0.0, 0, 0.0, 42), 0);
assert_eq!(typed_char_count(1.0, 0, 0.0, 42), 42);
assert_eq!(typed_char_count(100.0, 0, -1.0, 42), 0);
assert_eq!(typed_char_count(0.0, 0, 0.3, 0), 0);
}
#[test]
fn typed_slice_never_splits_a_character() {
let code = "aé😀b";
assert_eq!(code.chars().count(), 4);
assert_eq!(typed_slice(code, 0), "");
assert_eq!(typed_slice(code, 1), "a");
assert_eq!(typed_slice(code, 2), "aé");
assert_eq!(typed_slice(code, 3), "aé😀");
assert_eq!(typed_slice(code, 4), code);
assert_eq!(typed_slice(code, 99), code);
}
#[test]
fn cursor_blinks_on_a_thirty_frame_cycle() {
assert!((cursor_visibility(0.0) - 0.0).abs() < 1e-6);
assert!(cursor_visibility(1.0) > 0.0);
assert!((cursor_visibility(2.0) - 1.0).abs() < 1e-6);
assert!((cursor_visibility(10.0) - 1.0).abs() < 1e-6);
assert!((cursor_visibility(15.0) - 0.0).abs() < 1e-6);
assert!((cursor_visibility(20.0) - 0.0).abs() < 1e-6);
assert!((cursor_visibility(29.9) - 0.0).abs() < 1e-6);
assert!(cursor_visibility(31.0) > 0.0);
assert!((cursor_visibility(30.0) - 0.0).abs() < 1e-6);
assert!((cursor_visibility(-5.0) - 0.0).abs() < 1e-6);
for frame in 0..120 {
let value = cursor_visibility(frame as f32);
assert!((0.0..=1.0).contains(&value), "opacity {value} out of range");
}
}
#[test]
fn tokenizer_recognises_the_demo_code() {
let lines = tokenize(DEMO_CODE);
assert_eq!(lines.len(), 7);
assert_eq!(
kinds(&lines[0]),
vec![(TokenKind::Comment, "// components-generator")]
);
let export = &lines[1];
assert_eq!(export[0].kind, TokenKind::Keyword);
assert_eq!(export[0].text, "export");
assert_eq!(export[1].text, " ");
assert!(
export
.iter()
.any(|token| token.kind == TokenKind::Keyword && token.text == "string")
);
assert!(
export
.iter()
.any(|token| token.kind == TokenKind::Default && token.text == "greet")
);
let template = lines[2]
.iter()
.find(|token| token.kind == TokenKind::String)
.expect("the template literal");
assert_eq!(template.text, "`Hello, ${name}!`");
assert!(
lines[3]
.iter()
.any(|token| token.kind == TokenKind::Function && token.text == "log")
);
assert!(
lines[6]
.iter()
.any(|token| token.kind == TokenKind::Function && token.text == "greet")
);
assert!(
lines[3]
.iter()
.any(|token| token.kind == TokenKind::Default && token.text == "console")
);
assert!(
lines[3]
.iter()
.any(|token| token.kind == TokenKind::Default && token.text == "message")
);
for (line, tokens) in DEMO_CODE.split('\n').zip(&lines) {
let rebuilt: String = tokens.iter().map(|token| token.text.as_str()).collect();
assert_eq!(rebuilt, line);
}
}
#[test]
fn tokenizer_handles_numbers_strings_escapes_and_block_comments() {
let lines = tokenize("let x = 0x1F + 1.5e-3 + 42; // tail");
let texts: Vec<_> = lines[0].iter().map(|t| (t.kind, t.text.as_str())).collect();
assert!(texts.contains(&(TokenKind::Number, "0x1F")));
assert!(texts.contains(&(TokenKind::Number, "1.5e-3")));
assert!(texts.contains(&(TokenKind::Number, "42")));
assert!(texts.contains(&(TokenKind::Keyword, "let")));
assert!(texts.contains(&(TokenKind::Comment, "// tail")));
let escaped = tokenize(r#"a = "he said \"hi\"" + 'x';"#);
assert_eq!(
escaped[0]
.iter()
.filter(|t| t.kind == TokenKind::String)
.map(|t| t.text.as_str())
.collect::<Vec<_>>(),
vec![r#""he said \"hi\"""#, "'x'"]
);
let unterminated = tokenize("a = 'oops\nb = 2");
assert_eq!(unterminated.len(), 2);
assert_eq!(unterminated[0][2].kind, TokenKind::String);
assert_eq!(unterminated[1][2].kind, TokenKind::Number);
let block = tokenize("/* one\ntwo */ let a = 1;");
assert_eq!(block.len(), 2);
assert_eq!(block[0][0].kind, TokenKind::Comment);
assert_eq!(block[1][0].text, "two */");
assert_eq!(block[1][0].kind, TokenKind::Comment);
assert!(block[1].iter().any(|t| t.text == "let"));
assert_eq!(tokenize(""), vec![Vec::<Token>::new()]);
assert_eq!(
tokenize("\n"),
vec![Vec::<Token>::new(), Vec::<Token>::new()]
);
}
#[test]
fn tokenizer_does_not_loop_forever_on_odd_input() {
assert_eq!(tokenize("/")[0][0].text, "/");
assert_eq!(tokenize("a / b")[0].len(), 4);
assert_eq!(tokenize("=>{}")[0].len(), 1);
assert_eq!(tokenize("héllo_1")[0][0].text, "héllo_1");
assert_eq!(tokenize("'").len(), 1);
assert_eq!(tokenize("`a\\").len(), 1);
}
#[test]
fn prefix_lines_matches_a_slice_of_the_source() {
let lines = tokenize("ab\ncd");
let render = |count: usize| -> Vec<String> {
prefix_lines(&lines, count)
.iter()
.map(|line| line.iter().map(|t| t.text.as_str()).collect::<String>())
.collect()
};
assert_eq!(render(0), vec![""]);
assert_eq!(render(1), vec!["a"]);
assert_eq!(render(2), vec!["ab"]);
assert_eq!(render(3), vec!["ab", ""]);
assert_eq!(render(4), vec!["ab", "c"]);
assert_eq!(render(5), vec!["ab", "cd"]);
assert_eq!(render(99), vec!["ab", "cd"]);
}
#[test]
fn prefix_lines_truncates_mid_token_and_tracks_the_code() {
let code = "const value = 1234;\n// note";
let lines = tokenize(code);
for count in 0..=code.chars().count() {
let rebuilt: Vec<String> = prefix_lines(&lines, count)
.iter()
.map(|line| line.iter().map(|t| t.text.as_str()).collect::<String>())
.collect();
let expected: Vec<String> = typed_slice(code, count)
.split('\n')
.map(str::to_owned)
.collect();
assert_eq!(rebuilt, expected, "prefix of {count} characters");
}
}
#[test]
fn duration_covers_typing_and_the_hold_tail() {
let component = TypingCodeBlock::default();
assert_eq!(component.duration(), 45 + 90);
assert_eq!(component.meta().id, "typing-code-block");
assert_eq!(component.background(), Some("#0d1117"));
let delayed = TypingCodeBlock::new(TypingCodeBlockConfig {
code: "ab".to_owned(),
delay: 10,
typing_speed: 1.0,
..TypingCodeBlockConfig::default()
});
assert_eq!(delayed.duration(), 12 + 90);
let empty = TypingCodeBlock::new(TypingCodeBlockConfig {
code: String::new(),
..TypingCodeBlockConfig::default()
});
assert_eq!(empty.duration(), HOLD_TAIL_FRAMES);
}
#[test]
fn every_frame_renders_without_panicking() {
let component = TypingCodeBlock::default();
let meters = crate::embedded_meters();
let duration = component.duration();
assert_eq!(duration, 135);
for frame in 0..duration {
let mut raw = fframes::Frame::new(frame, frame, 30);
let info = crate::ctx::ComponentFrame {
local: frame as f32,
global: frame,
fps: 30.0,
duration,
width: 1920.0,
height: 1080.0,
};
let mut ctx = RenderCtx::new(&mut raw, crate::core::Fonts::default(), &meters, info);
let svg = component.show(&mut ctx);
assert!(
!format!("{svg}").is_empty(),
"frame {frame} produced nothing"
);
}
}
}