Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/rustmotion/rules/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ src/
│ │ ├── shapes.rs # rounded_rect, circle, arrow paths
│ │ └── text.rs # Skia text measurement (line metrics, wrapping)
│ └── text/
│ └── cosmic.rs # cosmic-text FontSystem global + Skia glyph bridge
│ └── cosmic.rs # cosmic-text FontSystem — dormant, pas sur le chemin de rendu
├── schema/ # JSON-serializable data models
│ ├── scenario.rs # Scenario, ResolvedScenario, View, ResolvedView, Scene, VideoConfig
│ ├── style.rs # Specialized types: CardBorder, CardShadow, Fill, Gradient, etc.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ crates/
│ │ ├── animator.rs # Résolution animations, easing, spring solver
│ │ ├── transition.rs # Transitions entre scènes
│ │ ├── renderer/ # Primitives Skia (colors, fonts, shapes, text)
│ │ └── text/cosmic.rs # Bridge cosmic-text ↔ Skia (mesure + glyphs)
│ │ └── text/cosmic.rs # Bridge cosmic-text — PAS branché sur le rendu réel
│ ├── schema/ # Modèles de données JSON
│ │ ├── scenario.rs # Scenario, ResolvedScenario, View, Scene, VideoConfig
│ │ ├── style.rs # Specialized types (CardBorder, CardShadow, Fill, etc.)
Expand Down
293 changes: 283 additions & 10 deletions crates/rustmotion-components/src/caption.rs

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions crates/rustmotion-components/src/codeblock/highlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,25 @@ pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
);

// #7: a custom/Google font declared in the scenario for `family` must
// win over the hardcoded monospace fallback list below — check the
// custom registry directly, first. Previously the only place that
// consulted it was `typeface_with_fallback`, reached solely through the
// final `.or_else` below; but the `fallbacks` list's `match_family_style`
// calls (which try `family` itself first, among plain system families)
// already return *something* on essentially every real system — Skia's
// system `FontMgr` almost never returns `None` for "JetBrains Mono"/
// "Fira Code"/"Menlo"/"Courier New"/"monospace" collectively — so that
// `.or_else` was never reached and a declared custom font (an Anton
// `.ttf`, a Google "IBM Plex Mono") was silently ignored (see commit
// b4603f9, which fixed the equivalent regression for `text`).
if let Some(typeface) =
rustmotion_core::engine::renderer::resolve_custom_typeface(family, style)
{
return Some(Font::from_typeface(typeface, size));
}

let fallbacks = [
family,
"JetBrains Mono",
Expand All @@ -556,3 +575,61 @@ pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight
})?;
Some(Font::from_typeface(typeface, size))
}

#[cfg(test)]
mod monospace_font_tests {
use super::*;

/// #7 reproduction: a codeblock's declared custom/Google font must
/// actually be used, not silently shadowed by the hardcoded monospace
/// fallback chain. Registers a real display face (Anton — visually
/// nothing like any of "JetBrains Mono"/"Fira Code"/"Menlo"/"Courier
/// New"/"monospace") under a family name that collides with none of
/// them, and asserts `resolve_monospace_font` actually resolves to it.
/// Skips on a cold font cache (no network access in CI) — the render QA
/// in `examples/` (e.g. `cb_anton.json`) is the visual counterpart.
#[test]
fn declared_custom_font_wins_over_hardcoded_monospace_fallbacks() {
let path = format!(
"{}/.cache/rustmotion/fonts/anton-400.ttf",
std::env::var("HOME").unwrap_or_default()
);
let Ok(bytes) = std::fs::read(&path) else {
return; // cold font cache → skip (render QA covers it)
};

let font_mgr = rustmotion_core::engine::renderer::font_mgr();
let parsed = font_mgr
.new_from_data(&skia_safe::Data::new_copy(&bytes), None)
.expect("cached TTF must parse");
let parsed_style = parsed.font_style();
rustmotion_core::engine::renderer::register_custom_font_variant(
"RmProbeCodeblockAnton",
bytes,
*parsed_style.weight(),
false,
);

let font = resolve_monospace_font("RmProbeCodeblockAnton", 20.0, FontWeight::Normal)
.expect("resolve_monospace_font must succeed");
assert_eq!(
font.typeface().family_name(),
"Anton",
"declared custom font must win over the hardcoded monospace fallback chain, got {}",
font.typeface().family_name()
);
}

/// Regression guard: an *undeclared* family (nothing in the custom
/// registry) must still fall through to a real monospace font via the
/// hardcoded chain, not fail or silently switch to some arbitrary
/// serif/sans system default.
#[test]
fn unregistered_family_still_falls_back_to_a_monospace_font() {
let font = resolve_monospace_font("RmProbeNoSuchFamilyXYZ", 20.0, FontWeight::Normal)
.expect("must still resolve a fallback font");
// Not asserting a specific family name (host-dependent) — just that
// resolution succeeds and doesn't panic/None out.
assert!(font.size() > 0.0);
}
}
43 changes: 37 additions & 6 deletions crates/rustmotion-components/src/codeblock/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ pub(super) fn render_codeblock(
let x = layout.x;
let y = layout.y;

let (pad_top, pad_right, _pad_bottom, pad_left) = padding;
let (pad_top, pad_right, pad_bottom, pad_left) = padding;
let corner_radius = layer.style.border_radius_px_or(12.0);

let bg_color = layer.style.background_color_str().unwrap_or("#2b303b");
Expand All @@ -114,8 +114,40 @@ pub(super) fn render_codeblock(
let code_x = x + pad_left + gutter_width;
let code_y = y + chrome_height + pad_top;

let scroll_offset = if layer.auto_scroll && natural_height > total_height + 0.5 {
natural_height - total_height
// #4: the non-transition (typewriter/reveal) path only paints
// `visible_lines` lines, not the full `current_code` — `natural_height`
// (used below) is the height of *all* the code, revealed or not. Using
// it for the scroll offset made the offset constant and maximal from
// t=0, translating the not-yet-revealed lines' eventual position
// upward by the full amount immediately: the first lines to reveal sit
// above the clip, invisible, until the reveal has caught up with that
// fixed offset (reproduced: 60% of a 4s typewriter reveal painted zero
// text pixels). Compute reveal state up front so the scroll offset can
// be based on what's actually drawn — matches `terminal.rs`'s
// `content_h = visible_lines * line_h + padding + chrome_h` formula,
// which has never had this bug.
let reveal_state = if transition.is_none() {
let highlighted = highlight_code(&current_code, &layer.language, theme);
let (visible_lines, visible_chars, last_line_opacity) =
compute_reveal(layer, time, &highlighted);
Some((highlighted, visible_lines, visible_chars, last_line_opacity))
} else {
None
};

// Diff transitions (`render_diff_transition`) always paint the entire
// lerped diff, with no partial reveal — `natural_height` (the lerped
// dims_a/dims_b height) already matches what gets drawn for that path,
// so it needs no `visible_lines` adjustment; only the reveal path did.
let drawn_height = match &reveal_state {
Some((_, visible_lines, _, _)) => {
*visible_lines as f32 * actual_line_height + pad_top + pad_bottom + chrome_height
}
None => natural_height,
};

let scroll_offset = if layer.auto_scroll {
(drawn_height - total_height).max(0.0)
} else {
0.0
};
Expand Down Expand Up @@ -149,9 +181,8 @@ pub(super) fn render_codeblock(
trans,
);
} else {
let highlighted = highlight_code(&current_code, &layer.language, theme);
let (visible_lines, visible_chars, last_line_opacity) =
compute_reveal(layer, time, &highlighted);
let (highlighted, visible_lines, visible_chars, last_line_opacity) =
reveal_state.expect("reveal_state is always Some when transition is None");

if layer.show_line_numbers {
draw_line_numbers(
Expand Down
Loading
Loading