termcinema-engine 0.1.0

🧠 Core typewriter-style terminal animation engine (SVG renderer) for termcinema
Documentation
//! SVG renderer for REPL-style command groups.
//!
//! This module implements the core logic for rendering interactive terminal
//! sessions as animated SVGs, where each group includes a prompt, command,
//! and multi-line output.
//!
//! It supports:
//! - Per-character typewriter animation for commands;
//! - Static fade-in for prompts and output lines;
//! - Consistent padding, spacing, and font alignment;
//! - Auto-sizing or fixed-width canvas layout;
//! - Embedded font support for portability and visual stability.
//!
//! Used by: [`render_repl_from_script`] via `glue.rs`

use crate::core::{extract_control_primitives, extract_style_primitives};
use crate::render::{
    CHAR_WIDTH_RATIO, FALLBACK_WIDTH, LINE_HEIGHT_RATIO, build_svg_footer, build_svg_header,
    escape_char_xml, escape_xml, indent, inject_embed_font_css,
};
use crate::{CommandGroup, ControlSpec, LayoutSpec, StyleSpec};

// ─────────────── 🎬 SVG Main Controller ───────────────

/// Render a complete REPL-style SVG, where each command group contains:
/// a prompt, a user-entered command, and the corresponding output.
///
/// # Parameters
/// - `groups`: A list of command groups. Each includes a prompt, a command string, and output lines.
/// - `style`: Visual style options including font, size, color, and animation speed.
/// - `layout`: Layout settings such as width, padding, and alignment.
/// - `control`: Animation timing configuration.
///
/// # Returns
/// A fully-assembled SVG string suitable for embedding in HTML or writing to a `.svg` file.
pub(crate) fn render_repl_svg(
    groups: &[CommandGroup],
    style: &StyleSpec,
    layout: &LayoutSpec,
    control: &ControlSpec,
) -> String {
    const GROUP_DELAY_MS: u32 = 750;
    const COMMAND_GAP: u32 = 8;

    // Extract animation control parameters
    let (frame_delay, fade_duration, _) = extract_control_primitives(control);

    // Extract style parameters
    let (font_size, font_family, fill_color, background_color) = extract_style_primitives(style);

    // Layout calculation
    let line_height = (font_size as f32 * LINE_HEIGHT_RATIO).round() as u32;
    let char_spacing = (font_size as f32 * CHAR_WIDTH_RATIO).round() as u32;
    let padding = layout.padding.unwrap_or(0);

    // Estimate canvas size
    let total_lines: usize = groups.iter().map(|g| 1 + 1 + g.output.len()).sum();
    let vh = total_lines as u32 * line_height + padding * 2;
    let vw = layout
        .width
        .unwrap_or_else(|| compute_auto_width(groups, char_spacing, padding));

    // Start rendering SVG content
    let mut out = String::new();
    out.push_str(&build_svg_header(
        layout,
        vw,
        vh,
        font_family,
        font_size,
        fill_color,
        background_color,
    ));
    let (_, font_face) = inject_embed_font_css(style);
    out.push_str(&font_face);
    out.push_str(&format!(
        "{indent}<text text-anchor=\"start\" dominant-baseline=\"middle\">\n",
        indent = indent(1)
    ));

    let mut y = padding;
    let mut global_char_index = 0;

    for group in groups {
        // Render prompt line
        let prompt_delay = global_char_index * frame_delay;
        build_prompt_line(
            &mut out,
            &group.prompt,
            y,
            padding,
            prompt_delay,
            fade_duration,
        );

        // Render command line
        let prompt_len = group.prompt.chars().count() as u32;
        let x = padding + prompt_len * char_spacing + COMMAND_GAP;
        build_command_line(
            &mut out,
            &group.command,
            x,
            y,
            frame_delay,
            &mut global_char_index,
            char_spacing,
            fade_duration,
        );

        y += line_height;

        // Render output lines
        let output_delay = global_char_index * frame_delay;
        build_output_lines(
            &mut out,
            &group.output,
            y,
            padding,
            line_height,
            output_delay,
            fade_duration,
        );
        y += (group.output.len() as u32) * line_height;

        // Advance to the next group with a delay buffer
        global_char_index += GROUP_DELAY_MS / frame_delay.max(1);
    }

    out.push_str(&format!("{}</text>\n", indent(1)));
    out.push_str(build_svg_footer());
    out
}

// ─────────────── 🎨 Content Rendering ───────────────

/// Render a prompt line (static fade-in).
///
/// Uses `<tspan>` + `<animate>` to create a delayed fade-in effect.
fn build_prompt_line(
    out: &mut String,
    prompt: &str,
    y: u32,
    x: u32,
    begin_ms: u32,
    char_duration: u32,
) {
    out.push_str(&format!(
        "{i2}<tspan x=\"{x}\" y=\"{y}\" style=\"opacity:0\">\n",
        i2 = indent(2),
        x = x,
        y = y,
    ));
    out.push_str(&format!(
        "{i3}<animate attributeName=\"opacity\" from=\"0\" to=\"1\" begin=\"{begin}ms\" dur=\"{duration}ms\" fill=\"freeze\" />\n",
        i3 = indent(3),
        begin = begin_ms,
        duration = char_duration,
    ));
    out.push_str(&format!(
        "{i3}{}\n{i2}</tspan>\n",
        escape_xml(prompt),
        i3 = indent(3),
        i2 = indent(2),
    ));
}

/// Render the command line with a typewriter animation.
///
/// Each character appears one by one, wrapped in its own `<tspan>` tag.
#[allow(clippy::too_many_arguments)]
fn build_command_line(
    out: &mut String,
    command: &str,
    mut x: u32,
    y: u32,
    speed: u32,
    global_index: &mut u32,
    char_spacing: u32,
    char_duration: u32,
) {
    for ch in command.chars() {
        let begin_ms = *global_index * speed;
        out.push_str(&format!(
            "{i2}<tspan x=\"{x}\" y=\"{y}\" style=\"opacity:0\">\n",
            i2 = indent(2),
            x = x,
            y = y,
        ));
        out.push_str(&format!(
            "{i3}<animate attributeName=\"opacity\" from=\"0\" to=\"1\" begin=\"{begin}ms\" dur=\"{duration}ms\" fill=\"freeze\" />\n",
            i3 = indent(3),
            begin = begin_ms,
            duration = char_duration,
        ));
        out.push_str(&format!(
            "{i3}{}\n{i2}</tspan>\n",
            escape_char_xml(ch),
            i3 = indent(3),
            i2 = indent(2)
        ));

        x += char_spacing;
        *global_index += 1;
    }
}

/// Render output lines (static fade-in per line).
///
/// Each output line fades in after the command is finished, without character-by-character animation.
fn build_output_lines(
    out: &mut String,
    lines: &[String],
    mut y: u32,
    x: u32,
    line_height: u32,
    begin_ms: u32,
    char_duration: u32,
) {
    for line in lines {
        out.push_str(&format!(
            r#"{i2}<tspan x="{x}" y="{y}" style="opacity:0" xml:space="preserve">{text}
{i3}<animate attributeName="opacity" from="0" to="1" begin="{begin}ms" dur="{duration}ms" fill="freeze" />
{i2}</tspan>
"#,
            i2 = indent(2),
            i3 = indent(3),
            x = x,
            y = y,
            text = escape_xml(line),
            begin = begin_ms,
            duration = char_duration,
        ));
        y += line_height;
    }
}

// ─────────────── 🔧 Helper Functions ───────────────

/// Automatically compute the SVG width when not explicitly set in layout.
///
/// Measures the longest visual line (prompt + command or longest output line)
/// and multiplies by character spacing, then adds left/right padding.
fn compute_auto_width(groups: &[CommandGroup], char_spacing: u32, padding: u32) -> u32 {
    let max_len = groups
        .iter()
        .map(|g| {
            let prompt_len = g.prompt.chars().count();
            let command_len = g.command.chars().count();
            let output_max = g
                .output
                .iter()
                .map(|s| s.chars().count())
                .max()
                .unwrap_or(0);
            prompt_len + command_len.max(output_max)
        })
        .max()
        .unwrap_or(0);

    let calculated_width = (max_len as u32 * char_spacing) + padding * 2;
    calculated_width.max(FALLBACK_WIDTH)
}