diff --git a/.claude/skills/rustmotion/rules/html-css-mental-model.md b/.claude/skills/rustmotion/rules/html-css-mental-model.md index 736b5b9..17619ac 100644 --- a/.claude/skills/rustmotion/rules/html-css-mental-model.md +++ b/.claude/skills/rustmotion/rules/html-css-mental-model.md @@ -83,17 +83,19 @@ Tout ce qui est **espace, alignement, distribution** se règle via les propriét | Besoin | Propriété | Sur quel élément | Exemple | |---|---|---|---| | Espace entre enfants frères | `gap` | Parent (flex ou grid) | `"gap": 24` | -| Espace entre contenu et bordure du container | `padding` | Le container lui-même | `"padding": 40` ou `"padding": [32, 48]` | -| Décaler UN seul enfant par rapport aux autres | `margin` | L'enfant en question | `"margin-top": 16` | +| Espace entre contenu et bordure du container | `padding` | Le container lui-même | `"padding": 40` ou `"padding": {"top": 32, "bottom": 32, "left": 48, "right": 48}` | +| Décaler UN seul enfant par rapport aux autres | `margin` | L'enfant en question | `"margin": {"top": 16}` | | Centrer horizontalement (axe principal = column) | `align-items: "center"` | Parent flex | `"align-items": "center"` | | Centrer verticalement (axe principal = column) | `justify-content: "center"` | Parent flex | `"justify-content": "center"` | -| Pousser un enfant à droite | `margin-left: "auto"` | Cet enfant | `"margin-left": "auto"` | +| Pousser un enfant à droite | `margin: {"left": "auto"}` | Cet enfant | `"margin": {"left": "auto"}` | | Élément prend tout l'espace restant | `flex-grow: 1` | L'enfant | `"flex-grow": 1` | | Alignement différent pour un seul enfant | `align-self` | L'enfant | `"align-self": "flex-end"` | | 2 colonnes égales | `grid-template-columns` | Parent grid | `["1fr","1fr"]` | | 3 colonnes proportionnelles | `grid-template-columns` | Parent grid | `["2fr","1fr","1fr"]` | | Colonne de taille fixe + reste | `grid-template-columns` | Parent grid | `[240, "1fr"]` | +**Piège `margin-top` / `margin-left` :** il n'existe **pas** de champ `margin-top`, `margin-left`, `margin-right`, `margin-bottom` séparé — seulement `margin: Option` (`CssStyle` a `deny_unknown_fields`, donc un `"margin-top"` fait échouer la désérialisation du composant entier, qui disparaît silencieusement de la vidéo). `margin` accepte soit une valeur uniforme (`"margin": 16`), soit un objet par côté avec les côtés omis valant 0 : `"margin": {"top": 16}`, `"margin": {"left": "auto"}`. `padding` a exactement la même forme (`padding: Option`) et la même limitation — pas de `padding-top` isolé, et pas de raccourci tableau `[v, h]` façon CSS shorthand : `"padding": {"top": 32, "bottom": 32, "left": 48, "right": 48}`, pas `"padding": [32, 48]`. + ### Règle de décision ``` @@ -174,8 +176,7 @@ Besoin d'une exception pour UN seul enfant ? { "type": "text", "position": "absolute", "x": 60, "y": 40 } // ✅ — padding sur le container, les enfants sont en flow -{ "type": "card", "style": { "padding": [40, 60], "gap": 24, "width": 900 }, "children": [...] } -// ↑top/bottom ↑left/right +{ "type": "card", "style": { "padding": { "top": 40, "bottom": 40, "left": 60, "right": 60 }, "gap": 24, "width": 900 }, "children": [...] } ``` --- diff --git a/.claude/skills/rustmotion/rules/module-structure.md b/.claude/skills/rustmotion/rules/module-structure.md index c72d631..6d1ffc5 100644 --- a/.claude/skills/rustmotion/rules/module-structure.md +++ b/.claude/skills/rustmotion/rules/module-structure.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e559b6b..50b85c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.) diff --git a/crates/rustmotion-cli/src/claude_md.rs b/crates/rustmotion-cli/src/claude_md.rs new file mode 100644 index 0000000..00a8da6 --- /dev/null +++ b/crates/rustmotion-cli/src/claude_md.rs @@ -0,0 +1,128 @@ +//! Merging rustmotion's guidance into a project `CLAUDE.md` without owning the file. +//! +//! `skills install` used to write `CLAUDE.md` wholesale and `skills uninstall` used +//! to delete it. Both treated a file the user authors as rustmotion's property: a +//! project with its own build notes lost them on install, and lost the file itself +//! on uninstall. +//! +//! Instead, rustmotion claims a delimited block and never touches anything outside +//! it. The markers are HTML comments so they stay invisible in rendered Markdown. + +pub const START: &str = ""; +pub const END: &str = ""; + +/// Byte range of the rustmotion block, markers included. +fn block_span(text: &str) -> Option<(usize, usize)> { + let start = text.find(START)?; + let end = text[start..].find(END)? + start + END.len(); + Some((start, end)) +} + +/// The document to write on install. +/// +/// - no file yet: the block alone; +/// - a file without a block: the block appended, existing content untouched; +/// - a file with a block: only the block replaced, in place. +pub fn merge(existing: Option<&str>, body: &str) -> String { + let block = format!("{START}\n{}\n{END}\n", body.trim_end()); + let Some(existing) = existing else { + return block; + }; + match block_span(existing) { + Some((start, end)) => { + let mut out = String::with_capacity(existing.len() + block.len()); + out.push_str(&existing[..start]); + out.push_str(block.trim_end()); + out.push_str(&existing[end..]); + out + } + None if existing.trim().is_empty() => block, + None => { + let mut out = existing.to_string(); + if !out.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + out.push_str(&block); + out + } + } +} + +/// The document to write on uninstall, or `None` when nothing rustmotion owns is +/// left and the file should be removed. +/// +/// A file the user also wrote in survives with its own content; a file that only +/// ever held our block is reported as removable. +pub fn strip(existing: &str) -> Option { + let Some((start, end)) = block_span(existing) else { + // No block: the file is entirely the user's. Never remove it. + return Some(existing.to_string()); + }; + let mut out = String::with_capacity(existing.len()); + out.push_str(&existing[..start]); + out.push_str(&existing[end..]); + if out.trim().is_empty() { + None + } else { + Some(format!("{}\n", out.trim_end())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BODY: &str = "# rustmotion\nGuidance."; + + #[test] + fn a_project_claude_md_survives_install_and_uninstall() { + let user = "# My Project\nBuild with `make`. Never delete this.\n"; + + let installed = merge(Some(user), BODY); + assert!( + installed.contains("Never delete this."), + "install destroyed the user's content: {installed}" + ); + assert!(installed.contains(BODY)); + + let uninstalled = strip(&installed).expect("a user-authored file is never removed"); + assert!(uninstalled.contains("Never delete this.")); + assert!( + !uninstalled.contains("Guidance."), + "uninstall left our block behind: {uninstalled}" + ); + assert!(!uninstalled.contains(START)); + } + + #[test] + fn reinstalling_replaces_the_block_instead_of_stacking_copies() { + let once = merge(None, BODY); + let twice = merge(Some(&once), "# rustmotion\nNewer guidance."); + assert_eq!(twice.matches(START).count(), 1, "block duplicated: {twice}"); + assert!(twice.contains("Newer guidance.")); + assert!(!twice.contains("Guidance.")); + } + + #[test] + fn a_file_we_alone_created_is_reported_as_removable() { + let ours = merge(None, BODY); + assert!(strip(&ours).is_none()); + } + + #[test] + fn a_file_without_our_block_is_returned_untouched() { + let user = "# Theirs\nnothing of ours here\n"; + assert_eq!(strip(user).as_deref(), Some(user)); + } + + #[test] + fn content_around_the_block_is_preserved_on_both_sides() { + let doc = format!("before\n\n{START}\nold\n{END}\n\nafter\n"); + let merged = merge(Some(&doc), BODY); + assert!(merged.starts_with("before")); + assert!(merged.trim_end().ends_with("after")); + assert!(merged.contains("Guidance.")); + assert!(!merged.contains("\nold\n")); + } +} diff --git a/crates/rustmotion-cli/src/commands/batch.rs b/crates/rustmotion-cli/src/commands/batch.rs index 5bf6d48..f1f358c 100644 --- a/crates/rustmotion-cli/src/commands/batch.rs +++ b/crates/rustmotion-cli/src/commands/batch.rs @@ -1,22 +1,29 @@ //! Batch rendering: render one video per line of a JSONL data file. //! //! Design invariants: -//! - All input is validated (JSONL parse + name template + variable checks) BEFORE -//! any render starts. A corrupt line does not waste render time. +//! - All input is validated (JSONL parse + name template + variable checks + +//! the same schema/geometry pass `render` runs, see `validation::run_checks`) +//! BEFORE any render starts. A corrupt or overflowing line does not waste +//! render time, and does not silently produce a bad video either. //! - Each data line is a JSON object; its fields become variable overrides. //! - `{field}` in the name template is replaced by the field's JSON-rendered value; -//! `{index}` by the 0-based line number. +//! `{index}` by the 0-based line number. The resolved name may create +//! subdirectories under `--output-dir` (e.g. `{lang}/{id}.mp4`) but may not +//! escape it: a `..` component or an absolute path is rejected in preflight. //! - Unknown variables (when the template has a `config` block) produce an actionable //! error listing declared variables — same as the single-file path. -//! - Exit code is non-zero if any render failed; partial success is reported. +//! - Exit code is non-zero if any render failed *or panicked* (`--jobs > 1` +//! dispatches renders across worker threads; a panic in one is caught and +//! counted as a failure, never silently dropped); partial success is reported. use rustmotion::error::{Result, RustmotionError}; use rustmotion::loader::load_input_with_vars; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; use crate::commands::render::cmd_render; +use crate::commands::validation::{self, ValidationSource}; /// One row parsed from the JSONL data file. struct BatchRow { @@ -72,6 +79,38 @@ pub(crate) fn resolve_name_template( Ok(out) } +/// Reject a resolved output name that would escape `--output-dir` once joined +/// onto it. `Path::join` replaces the base entirely when the joined path is +/// absolute, and a `..` component walks back out of it regardless — either +/// way `output_dir.join(name)` can land anywhere on disk. `name` comes from +/// `{field}` substitution of the (untrusted) JSONL data file, so this check +/// runs in preflight, before any render starts, same as every other +/// preflight check in this module. +/// +/// A plain relative path — including one that creates subdirectories, e.g. +/// `"en/abc.mp4"` — remains legal: only `ParentDir` (`..`), `RootDir` +/// (a leading `/`), and `Prefix` (a Windows drive/UNC root) are rejected. +fn reject_escaping_name(name: &str) -> std::result::Result<(), String> { + for component in Path::new(name).components() { + match component { + Component::ParentDir => { + return Err(format!( + "resolved output name '{name}' contains a '..' component, \ + which would escape --output-dir" + )) + } + Component::RootDir | Component::Prefix(_) => { + return Err(format!( + "resolved output name '{name}' is an absolute path, \ + which would escape --output-dir" + )) + } + Component::CurDir | Component::Normal(_) => {} + } + } + Ok(()) +} + /// Parse the JSONL file, resolve output names, and validate overrides against /// the scenario template (dry-run load). Returns ordered rows or an error if /// anything is invalid. No rendering happens here. @@ -121,12 +160,35 @@ fn preflight( continue; } }; + if let Err(msg) = reject_escaping_name(&name) { + preflight_errors.push(format!("line {}: {}", index + 1, msg)); + continue; + } let output_path = output_dir.join(&name); - // Validate overrides against the template (dry-run: load then discard) - let path_buf = template_path.to_path_buf(); - if let Err(e) = load_input_with_vars(&path_buf, Some(&overrides)) { - preflight_errors.push(format!("line {}: {}", index + 1, e)); + // Validate overrides against the template: parse, resolve variables, + // then run the same schema + geometry pass `render` runs before it + // renders a single-file scenario (`validation::run_checks`, wired + // with the same defaults `render` uses with no flags: blocking + // geometry violations, no animated-frame sampling). A batch row is + // exactly the situation the module doc warns about — N videos + // produced in one shot — so it must not be the one path that skips + // the "unwrappable_text_overflow" / viewport-overflow gate CLAUDE.md + // requires. The loaded scenario itself is discarded here: `render_row` + // reloads it below, right before the actual render. + let loaded = match validation::load_with_vars( + ValidationSource::File(template_path), + Some(&overrides), + ) { + Ok(l) => l, + Err(e) => { + preflight_errors.push(format!("line {}: {}", index + 1, e)); + continue; + } + }; + let report = validation::run_checks(&loaded, false); + if report.is_blocking(false) { + preflight_errors.push(format!("line {}: {}", index + 1, report.to_error())); continue; } @@ -274,7 +336,23 @@ pub fn cmd_batch( .collect(); for h in handles { - let _ = h.join(); // panics in threads are surfaced as failures below + // `h.join()` returns `Err` when the worker thread panicked instead + // of returning normally (e.g. a codec assertion on an odd frame + // dimension). That row was neither counted in `success_arc` nor + // pushed to `failures` by the closure above — it *only* runs its + // bookkeeping on the `Ok`/`Err` return path, which a panic skips + // entirely. Left unhandled, `_ = h.join()` was true to its + // comment ("panics ... are surfaced as failures below") in name + // only: nothing below ever inspected the join result, so a batch + // where every worker panicked reported "0/N succeeded" and still + // returned `Ok(())` — exit code 0 for a batch that rendered + // nothing. Record it as a failure explicitly instead. + if let Err(payload) = h.join() { + failures.lock().unwrap().push(format!( + "worker thread panicked: {}", + panic_message(&payload) + )); + } } success_count = *success_arc.lock().unwrap(); } @@ -282,6 +360,19 @@ pub fn cmd_batch( let failure_list = failures.lock().unwrap().clone(); let fail_count = failure_list.len(); + // Defense in depth: every row must end up counted as either a success or + // a failure. This should already hold given the panic handling above, + // but if it doesn't — a future refactor drops a bookkeeping update, a + // panic happens somewhere neither counter is touched — fail loudly + // rather than silently report a batch as complete when it wasn't. + if success_count + fail_count != total { + return Err(RustmotionError::Generic(format!( + "batch accounting mismatch: {success_count} succeeded + {fail_count} failed \ + != {total} total item(s) — {} item(s) neither succeeded nor were reported as failed", + total.saturating_sub(success_count + fail_count) + ))); + } + if !quiet { eprintln!("Batch complete: {}/{} succeeded.", success_count, total); } @@ -297,6 +388,23 @@ pub fn cmd_batch( Ok(()) } +/// Extract a human-readable message from a caught thread panic payload. +/// `std::thread::Result`'s `Err` variant is `Box`; panics +/// raised via `panic!("...")` / `.unwrap()` / `.expect(...)` box either a +/// `&'static str` or a `String`, which covers the vast majority of real +/// panics (including the openh264 `assert_eq!` this fix was written for). +/// Anything else (a custom payload via `std::panic::panic_any`) still +/// produces a readable, if generic, message instead of losing the row. +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + fn render_row( template_path: &Path, row: &BatchRow, @@ -382,6 +490,44 @@ mod name_template_tests { } } +#[cfg(test)] +mod escaping_name_tests { + use super::*; + + #[test] + fn plain_relative_name_is_allowed() { + assert!(reject_escaping_name("abc.mp4").is_ok()); + } + + /// `{lang}/{id}.mp4`-style names are a documented, legitimate feature + /// (`field_placeholder_resolved` above): rejecting every non-`Normal` + /// path component would also reject this, which is why only + /// `ParentDir`/`RootDir`/`Prefix` are rejected, not every multi-segment + /// name. + #[test] + fn relative_subdirectory_name_is_allowed() { + assert!(reject_escaping_name("en/abc.mp4").is_ok()); + } + + #[test] + fn parent_dir_component_is_rejected() { + let err = reject_escaping_name("../../escaped.mp4").unwrap_err(); + assert!( + err.contains(".."), + "error must call out the '..' component: {err}" + ); + } + + #[test] + fn absolute_unix_path_is_rejected() { + let err = reject_escaping_name("/tmp/scratch/pwned/absolute.mp4").unwrap_err(); + assert!( + err.contains("absolute"), + "error must call out the absolute path: {err}" + ); + } +} + #[cfg(test)] mod batch_integration_tests { use super::*; @@ -511,4 +657,344 @@ mod batch_integration_tests { let _ = std::fs::remove_file(&data); let _ = std::fs::remove_dir_all(&out_dir); } + + /// Constat #2/#4: a `{field}` value from the (untrusted) JSONL data file + /// containing `..` must not be able to walk `output_dir.join(name)` back + /// out of `--output-dir`. Reproduces the brief's scenario: `--output-dir + /// /sub/dir`, `--name-template '{name}.png'`, data field + /// `"../../escaped"` — before the fix this created + /// `/escaped.png/frame_00000.png`, outside `/sub/dir`. + #[test] + fn batch_rejects_name_that_escapes_output_dir_via_parent_dir() { + let template = write_json(&minimal_template("name"), "tmpl_escape_dotdot"); + let data = write_jsonl( + &[serde_json::json!({"name": "../../escaped"})], + "escape_dotdot", + ); + let base = std::env::temp_dir().join(format!("rm_batch_escdd_{}", std::process::id())); + let out_dir = base.join("sub").join("dir"); + std::fs::create_dir_all(&out_dir).unwrap(); + + let err = cmd_batch( + &template, + &data, + &out_dir, + "{name}.png", + None, + None, + Some("png-seq".to_string()), + false, + 1, + true, + ) + .expect_err("a name containing '..' must be rejected in preflight"); + + assert!( + err.to_string().contains(".."), + "error must call out the escaping component: {err}" + ); + + // `/sub/dir/../../escaped.png` lexically resolves to + // `/escaped.png` — it must never have been created. + let escaped_target = base.join("escaped.png"); + assert!( + !escaped_target.exists(), + "traversal target must not have been created: {}", + escaped_target.display() + ); + + let _ = std::fs::remove_file(&template); + let _ = std::fs::remove_file(&data); + let _ = std::fs::remove_dir_all(&base); + } + + /// Constat #2/#4, absolute-path variant: `Path::join` with an absolute + /// component discards the base entirely, so a `{field}` value that is + /// itself an absolute path writes straight to that path, ignoring + /// `--output-dir` completely. + #[test] + fn batch_rejects_name_that_is_an_absolute_path() { + let template = write_json(&minimal_template("name"), "tmpl_escape_abs"); + let escape_target = + std::env::temp_dir().join(format!("rm_batch_abs_escape_target_{}", std::process::id())); + let name_value = escape_target.to_string_lossy().to_string(); + let data = write_jsonl(&[serde_json::json!({"name": name_value})], "escape_abs"); + let out_dir = + std::env::temp_dir().join(format!("rm_batch_out_escabs_{}", std::process::id())); + std::fs::create_dir_all(&out_dir).unwrap(); + + let target_with_ext = PathBuf::from(format!("{}.png", escape_target.display())); + let _ = std::fs::remove_dir_all(&target_with_ext); + + let err = cmd_batch( + &template, + &data, + &out_dir, + "{name}.png", + None, + None, + Some("png-seq".to_string()), + false, + 1, + true, + ) + .expect_err("an absolute output name must be rejected in preflight"); + + assert!( + err.to_string().to_lowercase().contains("absolute"), + "error must call out the absolute path: {err}" + ); + assert!( + !target_with_ext.exists(), + "absolute-path target must not have been created: {}", + target_with_ext.display() + ); + + let _ = std::fs::remove_file(&template); + let _ = std::fs::remove_file(&data); + let _ = std::fs::remove_dir_all(&out_dir); + } + + /// The traversal fix must not regress the documented `{field}/{field}` + /// nested-subdirectory feature (`field_placeholder_resolved` unit test): + /// only `..`/absolute components are rejected, plain relative + /// subdirectories still get created under `--output-dir`. + #[test] + fn batch_relative_subdirectory_name_still_creates_nested_output() { + let template = write_json(&minimal_template("lang"), "tmpl_subdir"); + let data = write_jsonl(&[serde_json::json!({"lang": "en"})], "subdir"); + let out_dir = + std::env::temp_dir().join(format!("rm_batch_out_subdir_{}", std::process::id())); + std::fs::create_dir_all(&out_dir).unwrap(); + + cmd_batch( + &template, + &data, + &out_dir, + "{lang}/{index}.png", + None, + None, + Some("png-seq".to_string()), + false, + 1, + true, + ) + .expect("a relative subdirectory name must still be allowed"); + + let subdir = out_dir.join("en").join("0.png"); + assert!( + subdir.exists() && subdir.is_dir(), + "expected nested output dir en/0.png to exist" + ); + + let _ = std::fs::remove_file(&template); + let _ = std::fs::remove_file(&data); + let _ = std::fs::remove_dir_all(&out_dir); + } + + /// Constat #3: `batch` must run the same schema + geometry validation + /// `render` runs before it renders a single-file scenario — CLAUDE.md's + /// "schema + geometry, les deux doivent passer" rule applies just as + /// much to a batch row. Reproduces the brief's scenario: a 320×180 + /// template with 96px `white-space: nowrap` text and a long `$title` + /// override overflows the viewport; `render` blocks on it, so `batch` + /// must too, before any render starts. + #[test] + fn batch_preflight_rejects_a_geometry_overflow() { + let template = write_json( + &serde_json::json!({ + "config": { "title": { "type": "string", "default": "x" } }, + "video": { "width": 320, "height": 180, "fps": 1 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "text", + "content": "$title", + "style": { "font-size": 96, "white-space": "nowrap" } + }] + }] + }), + "tmpl_overflow", + ); + let data = write_jsonl( + &[serde_json::json!({"title": "A ridiculously long overflowing headline"})], + "overflow", + ); + let out_dir = + std::env::temp_dir().join(format!("rm_batch_out_overflow_{}", std::process::id())); + std::fs::create_dir_all(&out_dir).unwrap(); + + let err = cmd_batch( + &template, + &data, + &out_dir, + "{index}.png", + None, + None, + Some("png-seq".to_string()), + false, + 1, + true, + ) + .expect_err("a scenario that overflows the viewport must fail preflight, like `render`"); + + assert!( + err.to_string().contains("geometry violation"), + "error must surface the geometry violation, matching `render`'s message: {err}" + ); + assert!( + !out_dir.join("0.png").exists(), + "preflight failure must mean no render started" + ); + + let _ = std::fs::remove_file(&template); + let _ = std::fs::remove_file(&data); + let _ = std::fs::remove_dir_all(&out_dir); + } + + /// Guard that temporarily restricts `PATH` to a minimal, `ffmpeg`-free + /// set of directories, so `render`'s `Command::new("ffmpeg").arg( + /// "-version")` availability probe fails and the built-in openh264 + /// fallback encoder runs instead — the codepath the audit's own repro + /// deliberately forced with `env -i PATH=/usr/bin:/bin ...` to make the + /// `YUVBuffer` even-dimension assertion panic reachable without a real + /// `ffmpeg` dependency in CI. Restores the original `PATH` on drop. + struct NoFfmpegPathGuard { + original: Option, + _permit: std::sync::MutexGuard<'static, ()>, + } + + /// Serializes every test in this file that mutates `PATH` — required + /// because `std::env::set_var`/`remove_var` are `unsafe`: the standard + /// library only guarantees soundness when nothing else in the process + /// reads or writes the environment concurrently. This is the only test + /// file in `rustmotion-cli` whose tests spawn a real (non `png-seq` / + /// `gif` / `raw`) video encode — every other test in this crate's test + /// binary never reads `PATH` — so this lock only needs to protect + /// against concurrent runs of tests within this file. + static PATH_MUTATION_LOCK: Mutex<()> = Mutex::new(()); + + impl NoFfmpegPathGuard { + fn install() -> Self { + let permit = PATH_MUTATION_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let original = std::env::var_os("PATH"); + // SAFETY: `_permit` holds `PATH_MUTATION_LOCK` for this guard's + // entire lifetime (released on `Drop`, after `PATH` is restored + // below), and per the lock's doc comment no other thread in this + // binary reads/writes `PATH` while it is held. + unsafe { std::env::set_var("PATH", "/usr/bin:/bin") }; + Self { + original, + _permit: permit, + } + } + } + + impl Drop for NoFfmpegPathGuard { + fn drop(&mut self) { + // SAFETY: see `install`. + unsafe { + match &self.original { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + } + } + + /// Constat #1: with `--jobs > 1`, a worker thread that panics (instead of + /// returning `Err`) was invisible to `cmd_batch` — `let _ = h.join()` + /// discarded the panic, so neither `success_arc` nor `failures` was ever + /// incremented for that row. A batch where *every* worker panicked + /// reported "Batch complete: 0/N succeeded." and still returned `Ok(())` + /// — exit code 0 for a batch that rendered nothing. + /// + /// The brief's own reproduction (odd frame dimensions, e.g. 321×241, + /// forcing an `openh264` `assert_eq!(width % 2, 0, ...)` panic) is no + /// longer reachable *through `batch`* once constat #3 is fixed: schema + /// validation (`video.width and video.height must be even`, wired in by + /// the constat #3 fix above) now rejects that scenario in preflight, + /// before any worker thread is even spawned — a nice side effect, but it + /// means this test needs a different panic that survives preflight. + /// `create_encoder` (crates/rustmotion/src/encode/video/h264.rs) computes + /// `let pixels = width * height;` in plain (checked) `u32` arithmetic + /// before ever touching a frame buffer; 70000×70000 is even, positive, + /// and passes every schema/geometry check, but `70000 * 70000 = + /// 4_900_000_000` overflows `u32::MAX` (4_294_967_295) and panics with + /// "attempt to multiply with overflow" — deterministically, and before + /// any expensive Skia canvas allocation happens for the (nonexistent) + /// video frame. Still requires the openh264 fallback (`create_encoder` + /// is only reached when `ffmpeg` is unavailable), hence + /// `NoFfmpegPathGuard`. + #[test] + fn batch_parallel_worker_panic_is_reported_as_failure_not_silent_success() { + let template = write_json( + &serde_json::json!({ + "video": { "width": 70000, "height": 70000, "fps": 1 }, + "scenes": [{ "duration": 1.0, "children": [] }] + }), + "tmpl_overflow_dims", + ); + let data = write_jsonl( + &[serde_json::json!({}), serde_json::json!({})], + "overflow_dims", + ); + let out_dir = + std::env::temp_dir().join(format!("rm_batch_out_overflowdims_{}", std::process::id())); + std::fs::create_dir_all(&out_dir).unwrap(); + + let _no_ffmpeg = NoFfmpegPathGuard::install(); + + // Run `cmd_batch` on a worker so a hang (e.g. a deadlock introduced + // by a broken fix) fails the test instead of wedging the whole + // suite, mirroring `paint_within` in + // crates/rustmotion-components/tests/degenerate_inputs.rs. + let (tx, rx) = std::sync::mpsc::channel(); + let t_template = template.clone(); + let t_data = data.clone(); + let t_out_dir = out_dir.clone(); + let worker = std::thread::spawn(move || { + let result = cmd_batch( + &t_template, + &t_data, + &t_out_dir, + "{index}.mp4", + None, + None, + None, // default format: the mp4/openh264 path under test + false, + 2, // jobs: exercises the parallel path constat #1 is about + true, + ); + let _ = tx.send(result); + }); + + let result = match rx.recv_timeout(std::time::Duration::from_secs(30)) { + Ok(r) => { + worker + .join() + .expect("outer worker thread must not itself panic"); + r + } + Err(_) => { + panic!("cmd_batch did not return within 30s — possible deadlock in the join/accounting fix") + } + }; + + assert!( + result.is_err(), + "a batch where every worker panicked must not report success" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.to_lowercase().contains("panic"), + "the failure must surface the panic, not just an opaque render failure: {msg}" + ); + + let _ = std::fs::remove_file(&template); + let _ = std::fs::remove_file(&data); + let _ = std::fs::remove_dir_all(&out_dir); + } } diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index ddf85fd..a70cc5e 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -13,8 +13,6 @@ //! * detect wrapping content whose natural size exceeds its own resolved //! box (`text`/`gradient_text`/`caption`/`rich_text`/`table` — #128 //! item 1: originally `text`-only) -//! * detect a component's own box extending past the nearest ancestor -//! `card` that contains it, for every component type (#128 item 2) //! * detect terminal/codeblock content that overflows their box when //! `auto_scroll: false` //! * exempt `marquee` and `cursor` (designed to bleed) @@ -22,31 +20,54 @@ //! `auto` ancestor as a viewport overflow (H4) — the ancestor's own bbox //! is still checked independently, at its own level //! +//! Deliberately NOT in scope: a component's box vs its nearest ancestor +//! `card`'s box, independent of the viewport (#128 item 2, briefly added +//! then retired — round 4 audit, constat 7). CLAUDE.md and +//! `geometry-safety.md` both promise the validator only complains about +//! content escaping the *viewport*, never about escaping a non-clipping +//! (`overflow: visible`, the default) container — "a badge sticking out of +//! a card is legal". A box-vs-card check can only ever fire in exactly that +//! legal case (a clipping card already suppresses it the same way it +//! suppresses every other check here, so there is nothing left for it to +//! report when the card *does* clip either) — see the retired call site's +//! comment in `walk` for the full reasoning. +//! //! Animation handling is layered: by default we only check the resting -//! (untransformed) layout. With `--strict-anim`, we additionally sample -//! frames — proportionally to scene duration — and reapply the *real* -//! renderer's animation resolution (`effective_effects` + -//! `resolve_props_for_effects`) plus the paint pass's start_at/end_at -//! visibility window, rather than a hand-rolled fork of that logic (H6). +//! (untransformed) layout, built once with `anim: None`. With +//! `--strict-anim`, we additionally sample frames — proportionally to scene +//! duration (H6; round 4 audit, constat 8: dense enough to stay near a +//! promised 8/s up to 60s scenes) — and at EACH sample, rebuild the box +//! tree and rerun layout with a real `BuildAnimationCtx` (round 4 audit, +//! constats 2 & 9): the same engine path `render_with_new_pipeline_iter` +//! calls once per rendered frame, rather than building once at rest and +//! hand-deriving only translate/scale afterwards. This is what makes +//! `timeline` style states, audio-reactive transforms, and animated +//! rotation all visible to `--strict-anim`, not just translate/scale — see +//! `validate_geometry_animated`'s doc comment. The paint pass's +//! start_at/end_at visibility window (resolved at box-tree build time, +//! independent of `anim`) is honoured the same way in both modes. //! //! This walker runs the new CSS-engine pipeline (taffy + cosmic-text) so the //! geometry it checks matches what the renderer will actually paint. use std::collections::HashSet; -use rustmotion::components::box_builder::{build_scene_from_refs, effective_effects}; +use rustmotion::components::box_builder::{ + build_scene_from_refs, effective_effects, BuildAnimationCtx, +}; use rustmotion::components::intrinsic::{ - CaptionIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic, TextIntrinsic, + CaptionIntrinsic, CodeblockIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic, + TerminalIntrinsic, TextIntrinsic, }; use rustmotion::components::{ChildComponent, Component}; -use rustmotion::core::css::style::{CssStyle, TransformFn, WhiteSpace}; +use rustmotion::core::css::style::{CssStyle, TransformFn, TransformOrigin, WhiteSpace}; use rustmotion::core::css::taffy_bridge::ConversionContext; -use rustmotion::core::css::units::LengthContext; -use rustmotion::core::engine::box_tree::{AvailableSpace, BoxNode, IntrinsicMeasure}; +use rustmotion::core::css::units::{parse_origin_component, LengthContext, ParsedLength}; +use rustmotion::core::engine::box_tree::{AvailableSpace, BoxKind, BoxNode, IntrinsicMeasure}; use rustmotion::core::engine::layout_pass::{run_layout, BoxLayout, LayoutResult}; use rustmotion::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion::engine::render; -use rustmotion::schema::{Camera, ResolvedScenario, Scene}; +use rustmotion::schema::{Camera, ResolvedScenario, Scene, ViewType}; use serde::Serialize; /// One detected layout violation. @@ -94,13 +115,17 @@ pub enum ViolationKind { /// painters never clip themselves, so this paints outside its box /// regardless of where that box sits relative to the viewport. ContentOverflowsBox, - /// A component's own (resolved, post-layout) box extends past the - /// nearest ancestor `card` that contains it — distinct from - /// [`ViolationKind::ViewportOverflow`] (box vs frame) and - /// [`ViolationKind::ContentOverflowsBox`] (content vs its OWN box): this - /// is box vs the panel that's supposed to contain it (#128 item 2). A - /// panel-based style relies on every graphic element staying inside its - /// card, so this is the blind spot that matters most for it. + /// Retired (round 4 audit, constat 7) — no longer constructed by + /// `walk`/`walk_anim`. Was: a component's own (resolved, post-layout) + /// box extending past its nearest ancestor `card`'s box (#128 item 2), + /// unconditionally on any non-clipping card — exactly the "badge + /// sticking out of a card" pattern CLAUDE.md and `geometry-safety.md` + /// document as legal (`overflow: visible`, the default). Kept as a + /// variant — not renamed/removed — for `--fix`'s match arm and + /// `--report` JSON schema stability (frozen violation-kind contract); + /// see the module doc comment's "Deliberately NOT in scope" note for + /// the full reasoning. + #[allow(dead_code)] // never constructed by design — see doc comment above ContentOverflowsCard, /// Animated transform (scale/translate/wiggle/orbit) pushes the bbox out /// of the viewport at some sampled time. Only emitted with `--strict-anim`. @@ -120,13 +145,34 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec let mut violations = Vec::new(); for (vi, view) in scenario.views.iter().enumerate() { for (si, scene) in view.scenes.iter().enumerate() { + // Round 4 audit, constat 4: a `world` scene's decorative + // children (particles) are never fed into the flex box tree at + // render time either — `render_world_frame_scaled` paints them + // full-viewport via `paint_decorative_fullscreen`, filtered out + // of `render_with_new_pipeline_iter`'s children entirely (see + // `scene_children.iter().filter(|c| !c.is_decorative())` there). + // Leaving them in here would let them occupy a flex slot that + // pushes sibling positions around in a way that never happens + // at render, so they're dropped from the walk the same way for + // `world` views only — `slide` views never filtered them (a + // particle IS flex-flowed there), so scoping this to `world` + // keeps slide-view behaviour byte-identical. + let is_world = matches!(view.view_type, ViewType::World); let indexed = deserialize_children_indexed(scene); + let indexed: Vec<(usize, ChildComponent)> = if is_world { + indexed + .into_iter() + .filter(|(_, c)| !c.is_decorative()) + .collect() + } else { + indexed + }; let raw_indices: Vec = indexed.iter().map(|(i, _)| *i).collect(); let children: Vec = indexed.into_iter().map(|(_, c)| c).collect(); let viewport = (scenario.video.width, scenario.video.height); let viewport_f = (viewport.0 as f32, viewport.1 as f32); - let root_css = render::root_style(scene.layout.as_ref()); + let root_css = render::root_style(scene.layout.as_ref(), view.view_type.clone()); let built = build_scene_from_refs(children.iter(), viewport_f, root_css, None); let layouts = run_layout(&built.root, viewport_f, &ConversionContext::default()); @@ -151,9 +197,6 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec /*parent_clips=*/ false, camera, - // No ancestor card at the top of a scene. - /*nearest_card=*/ - None, &mut violations, ); } @@ -213,14 +256,6 @@ fn walk( path_indices: Option<&[usize]>, parent_clips: bool, camera: Option<&Camera>, - // Bbox of the nearest ancestor `card`, in the same raw layout space as - // `raw_bbox` below — `None` when no card encloses this level yet. Only - // `Component::Card` updates this for its own children (see the - // recursion below); every other container (`flex`/`grid`/`positioned`/ - // `container`) is layout-only per CLAUDE.md and passes it through - // unchanged, so the check always compares against the panel actually - // responsible for containing the content, not an incidental layout box. - nearest_card: Option, out: &mut Vec, ) { let viewport_f = (viewport.0 as f32, viewport.1 as f32); @@ -241,15 +276,30 @@ fn walk( } check_viewport(&child.component, &child_path, &vbbox, viewport, vi, si, out); } - check_unwrappable_text( - &child.component, - &child_path, - &raw_bbox, - viewport, - vi, - si, - out, - ); + // Round 4 audit, constat 3: this natural-width-vs-own-box check + // is content vs its OWN box, exactly the same category as + // `check_content_overflows_box` below (just for the nowrap/ + // single-line case instead of the wrapped one) — so it gets the + // identical double exemption: an ancestor that clips + // (`parent_clips`) genuinely crops the overflowing line before + // it can paint past the box, and a node that clips ITSELF + // (`container_clips`) does the same to its own content. Before + // this fix it ran unconditionally, contradicting + // geometry-safety.md's documented promise ("A node is also + // exempt when it clips itself, or when any ancestor clips it") + // and `--fix` would then strip a legitimate `white-space: + // nowrap` from a component that was never actually broken. + if !parent_clips && !container_clips(&child.component) { + check_unwrappable_text( + &child.component, + &child_path, + &raw_bbox, + viewport, + vi, + si, + out, + ); + } check_auto_scroll( &child.component, &child_path, @@ -275,35 +325,34 @@ fn walk( out, ); } - // #128 item 2: box vs the containing card. Suppressed under a - // clipping ancestor exactly like check_viewport (content clipped - // by the card itself, or by something between here and the - // card, genuinely never paints past it). Deliberately NOT - // suppressed by `bleed` — bleed is an assertion about crossing - // the *frame* edge on purpose, not about disowning whatever a - // component paints inside its own panel (see `bleeds`'s doc - // comment; the same reasoning `check_content_overflows_box` - // already applies). - if !parent_clips { - check_overflows_card( - &child.component, - &child_path, - &raw_bbox, - nearest_card, - viewport, - vi, - si, - out, - ); - } + // #128 item 2 (`ContentOverflowsCard`) used to live here: a + // component's box vs its nearest ancestor `card`'s box, + // unconditionally (as long as nothing clipped in between). + // Round 4 audit, constat 7: that check is structurally + // incompatible with the validator's own documented contract. + // Its own suppression (`!parent_clips`, mirroring every other + // check here) is reachable if and only if the nearest card AND + // everything between it and this node is non-clipping — i.e. it + // could only ever fire in exactly the case CLAUDE.md ("le + // validateur ne se plaint que si le contenu sort du viewport, + // pas d'un parent visible") and geometry-safety.md:34/77 ("a + // badge sticking out of a card is legal" when the card's + // `overflow` is `visible`, the default — "no change needed") + // both promise is legal and must NOT be reported. Whenever the + // card *does* clip (`overflow: hidden`), `parent_clips` already + // suppresses this whole block, so the content is invisible + // anyway and there is nothing left to warn about either way. + // There is no configuration where firing is both reachable and + // consistent with the documented contract, so it is retired + // here rather than patched with a redundant escape hatch — + // `check_overflows_card` (and the `nearest_card` tracking that + // fed it) is deleted; the `ViolationKind::ContentOverflowsCard` + // variant itself is kept, unconstructed, for `--fix`'s match arm + // and `--report` JSON schema stability (frozen violation-kind + // contract — see that variant's doc comment). } if let Some(grandchildren) = container_children(&child.component) { - let child_nearest_card = if matches!(child.component, Component::Card(_)) { - Some(raw_bbox) - } else { - nearest_card - }; walk( grandchildren, &box_node.children, @@ -315,7 +364,6 @@ fn walk( None, parent_clips || container_clips(&child.component), camera, - child_nearest_card, out, ); } @@ -396,8 +444,10 @@ fn container_clips(c: &Component) -> bool { /// closes the rotation/skew gap) — maps the box's four corners through the /// same ordered transform-function chain the paint pass's 2D fast path /// applies (`canvas.translate/scale/rotate/skew`, called once per function -/// in `style.transform` order, pivoted at the box centre), then takes the -/// AABB of the four transformed corners. This is what makes rotation/skew +/// in `style.transform` order, pivoted at `style.transform-origin` — +/// resolved by [`resolve_transform_origin_2d`], defaulting to the box centre +/// exactly like the paint pass does when it's absent), then takes the AABB +/// of the four transformed corners. This is what makes rotation/skew /// contribute correctly: an exact AABB under rotation needs the four /// corners, not a translate/scale-only shortcut. /// @@ -406,11 +456,9 @@ fn container_clips(c: &Component) -> bool { /// `Matrix3d`) and the general 2D `Matrix` are intentionally still not /// modeled (identity for that function) — an exact AABB there needs /// projecting through the full 3D pipeline `apply_transform` uses for that -/// path, out of scope for this fix. Also still assumes the pivot is the box -/// centre (no `transform-origin` support), matching the prior partial -/// behaviour. Animated transform-producing presets are folded separately in -/// `walk_anim`; this only handles what a component declares directly in -/// `style.transform`. +/// path, out of scope for this fix. Animated transform-producing presets are +/// folded separately in `walk_anim`; this only handles what a component +/// declares directly in `style.transform`. fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)) -> BBox { let transform = match css.transform.as_deref() { Some(t) if !t.is_empty() => t, @@ -423,8 +471,7 @@ fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32) font_size: 16.0, root_font_size: 16.0, }; - let pivot_x = bbox.x + bbox.w / 2.0; - let pivot_y = bbox.y + bbox.h / 2.0; + let (pivot_x, pivot_y) = resolve_transform_origin_2d(css.transform_origin.as_ref(), bbox, &ctx); let corners = [ (bbox.x, bbox.y), (bbox.x + bbox.w, bbox.y), @@ -453,6 +500,62 @@ fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32) } } +/// Round 4 audit, constat 5: resolve `style.transform-origin` to an absolute +/// viewport-space pivot `(x, y)`, in the same way the paint pass's own +/// `resolve_origin` does (`crates/rustmotion-core/src/engine/paint_pass.rs`) +/// — percentages resolve against the box's own width (x) / height (y), an +/// absent axis defaults to 50%, and an absent `transform-origin` altogether +/// defaults to dead-centre. +/// +/// This mirrors `resolve_origin`'s 2D resolution rather than calling it +/// directly: that function is private to `paint_pass.rs`, which sits outside +/// this workstream's file perimeter (round 4 audit, lot VALIDATION +/// GÉOMÉTRIQUE — geometry.rs/validate.rs/scene.rs only), so it cannot be +/// marked `pub`/re-exported from here without touching a file outside that +/// scope. What's duplicated is only the small resolution *orchestration*; +/// the actual unit-conversion primitives it calls (`parse_origin_component`, +/// `ParsedLength::resolve`) are `pub` in `rustmotion_core::css::units` and +/// are the exact same functions `resolve_origin` itself calls, so the two +/// can only drift on the orchestration shape, not on what a given length +/// string resolves to. Keep this in sync with `resolve_origin` if that +/// function's resolution rules change; the z component is intentionally not +/// resolved (this fold is 2D-only, see this function's caller's doc comment +/// on the 3D exemption). +fn resolve_transform_origin_2d( + origin: Option<&TransformOrigin>, + bbox: &BBox, + ctx: &LengthContext, +) -> (f32, f32) { + let Some(o) = origin else { + return (bbox.x + bbox.w / 2.0, bbox.y + bbox.h / 2.0); + }; + let resolve_axis = |lp: &rustmotion::core::css::units::LengthPercentage, + axis_size: f32, + axis_origin: f32| + -> f32 { + let parsed = match lp { + rustmotion::core::css::units::LengthPercentage::String(s) => { + parse_origin_component(s).unwrap_or(ParsedLength::Percent(50.0)) + } + rustmotion::core::css::units::LengthPercentage::Px(v) => ParsedLength::Px(*v), + }; + let local_ctx = LengthContext { + parent_size: axis_size, + ..*ctx + }; + axis_origin + parsed.resolve(&local_ctx).unwrap_or(axis_size / 2.0) + }; + let ox = + o.x.as_ref() + .map(|lp| resolve_axis(lp, bbox.w, bbox.x)) + .unwrap_or(bbox.x + bbox.w / 2.0); + let oy = + o.y.as_ref() + .map(|lp| resolve_axis(lp, bbox.h, bbox.y)) + .unwrap_or(bbox.y + bbox.h / 2.0); + (ox, oy) +} + /// Apply a `style.transform` function list to a point already expressed /// relative to the pivot, in the same order `apply_transform`'s 2D fast path /// composes them: `canvas.translate/scale/rotate/skew` are called once per @@ -781,67 +884,20 @@ fn check_content_overflows_box( }); } -/// #128 item 2: does this component's own (resolved) box extend past the -/// nearest ancestor `card`'s own box? Complementary to `check_viewport` -/// (box vs frame) and `check_content_overflows_box` (content vs its OWN -/// box) — this is the missing third comparison: box vs the panel it's -/// supposed to live inside. Operates purely in layout space (no camera/ -/// css-transform folding), matching `check_content_overflows_box`'s scope: -/// "does this content structurally fit inside its card as laid out", -/// independent of wherever the camera happens to be pointing at paint time. -fn check_overflows_card( - component: &Component, - path: &str, - bbox: &BBox, - nearest_card: Option, - viewport: (u32, u32), - vi: usize, - si: usize, - out: &mut Vec, -) { - let Some(card) = nearest_card else { - return; - }; - let eps = 0.5; - let card_right = card.x + card.w; - let card_bottom = card.y + card.h; - let right = bbox.x + bbox.w; - let bottom = bbox.y + bbox.h; - let x_over = bbox.x < card.x - eps || right > card_right + eps; - let y_over = bbox.y < card.y - eps || bottom > card_bottom + eps; - if !x_over && !y_over { - return; - } - let axis = match (x_over, y_over) { - (true, true) => Axis::Both, - (true, false) => Axis::X, - (false, true) => Axis::Y, - (false, false) => return, - }; - out.push(GeometryViolation { - view_index: vi, - scene_index: si, - path: path.to_string(), - component: component_kind(component).to_string(), - axis, - kind: ViolationKind::ContentOverflowsCard, - bbox: *bbox, - viewport, - hint: format!( - "{} at [{:.0},{:.0}]→[{:.0},{:.0}] extends past its containing card [{:.0},{:.0}]→[{:.0},{:.0}] — grow the card, shrink/reflow the content, or set overflow: hidden on the card if the bleed is intentional", - component_kind(component), - bbox.x, - bbox.y, - right, - bottom, - card.x, - card.y, - card_right, - card_bottom, - ), - }); -} - +/// Round 4 audit, constat 6: this used to hand-roll codeblock/terminal +/// natural-height formulas with a hardcoded 16+16=32px padding assumption +/// and (for terminal) the CSS `style.line-height` property — neither of +/// which is what actually gets painted. `CodeblockIntrinsic`/ +/// `TerminalIntrinsic` are the exact measurers `component_intrinsic` +/// (`box_builder.rs`) hands to the layout pass for these two components, so +/// calling them here — instead of re-deriving the formula — keeps this +/// check byte-for-byte in sync with `compute_code_dimensions` (codeblock, +/// which DOES read `style.padding_px()`) and `terminal::line_height()` +/// (terminal, which does NOT honour `style.line-height`, always using its +/// own fixed `LINE_HEIGHT`/`FONT_SIZE` ratio). Measuring at +/// `(None, None)`/`MaxContent` yields each component's natural (unbounded) +/// size, exactly like `check_unwrappable_text`/`check_content_overflows_box` +/// already do for the text-family intrinsics. fn check_auto_scroll( component: &Component, path: &str, @@ -851,18 +907,11 @@ fn check_auto_scroll( si: usize, out: &mut Vec, ) { + let max_content = (AvailableSpace::MaxContent, AvailableSpace::MaxContent); match component { Component::Codeblock(cb) if !cb.auto_scroll => { - let font_size = cb.style.font_size_px_or(14.0); - let actual_line_height = cb.style.line_height_for(font_size); - let line_count = cb.code.lines().count().max(1) as f32; - let chrome_h = if cb.chrome.as_ref().is_some_and(|c| c.enabled) { - 36.0 - } else { - 0.0 - }; - let pad = 32.0; // ~16 top + 16 bottom default - let natural_h = chrome_h + pad + line_count * actual_line_height; + let (_, natural_h) = + CodeblockIntrinsic::from_codeblock(cb).measure((None, None), max_content); if natural_h > bbox.h + 0.5 { out.push(GeometryViolation { view_index: vi, @@ -881,11 +930,8 @@ fn check_auto_scroll( } } Component::Terminal(t) if !t.auto_scroll => { - let font_size = t.style.font_size_px_or(16.0); - let actual_line_height = t.style.line_height_for(font_size); - let chrome_h = if t.show_chrome { 36.0 } else { 0.0 }; - let pad = 32.0; - let natural_h = chrome_h + pad + t.lines.len() as f32 * actual_line_height; + let (_, natural_h) = + TerminalIntrinsic::from_terminal(t).measure((None, None), max_content); if natural_h > bbox.h + 0.5 { out.push(GeometryViolation { view_index: vi, @@ -1125,7 +1171,24 @@ fn component_kind(c: &Component) -> &'static str { /// on long, mostly-static scenes). const ANIM_SAMPLES_PER_SECOND: f64 = 8.0; const ANIM_MIN_SAMPLES: usize = 5; -const ANIM_MAX_SAMPLES: usize = 40; +/// Round 4 audit, constat 8: raised from 40 (a ~5s ceiling on the promised +/// 8/s cadence) to 480 - 60s worth of samples at exactly 8/s, the audit's +/// own reference duration ("pas de 0.51s a 20s, 1.0s a 40s, 1.5s a 60s"). +/// Past a 5s scene, the old cap widened the step linearly with duration +/// (0.51s at 20s, 1.0s at 40s, 1.5s at 60s), so a brief transform excursion +/// shorter than that step could land entirely between two samples and never +/// get checked. Cost, measured on the box-tree-rebuild-per-sample walker +/// this cap now drives (constats 2 & 9): a 15-component animated scene at +/// 60s / 480 samples took 309ms wall-clock in a `--release` build +/// (~0.64ms/sample) and 543ms in a debug build (~1.13ms/sample) - see +/// `timing_probe_for_constat_8` (run with `--ignored`) for the harness. +/// `--strict-anim` is opt-in, and `validate`/`render`'s implicit checks +/// don't pass it, so this cost is paid only when explicitly asked for. +/// Scenes longer than 60s still degrade past this cap - CLAUDE.md's own +/// architecture favours many short scenes stitched by transitions/world +/// panning over one very long scene, so a single-scene ceiling at 60s +/// covers the documented common case. +const ANIM_MAX_SAMPLES: usize = 480; /// Sample times (seconds, scene-relative) for `--strict-anim`, spaced evenly /// across `[0, scene_duration]`. Count scales with `scene_duration` (H6) — @@ -1149,25 +1212,48 @@ fn anim_sample_times(scene_duration: f64) -> Vec { /// animation resolution to each widget's bbox, and report viewport /// overflows. Only emits `AnimatedTextOverflow` violations: the /// resting-layout checks live in `validate_geometry`. +/// +/// Round 4 audit, constats 2 & 9: rebuilds the box tree AND reruns layout at +/// EACH sampled time, with a real `BuildAnimationCtx` — exactly the engine +/// path `render_with_new_pipeline_iter` calls once per rendered frame +/// (`build_scene_from_refs` + `run_layout`) — instead of building once at a +/// frozen resting state (`anim: None`) and hand-deriving only +/// translate/scale afterwards in `walk_anim`. This one change fixes two +/// separate blind spots at once, because both are downstream of the SAME +/// `anim: None`: +/// * `build_child` only applies `apply_style_states` (`timeline` steps) +/// and the audio-reactive CSS block at the times a REAL `local_actx` is +/// available — a `timeline` step that changes a box-model property +/// (e.g. `width`) was invisible at every sample (constat 2). +/// * `apply_animated_props` bakes the resolved transform (translate, +/// scale, AND rotation) into `css.transform` — so once the tree is +/// rebuilt with the real time, `apply_static_node_transform` (already +/// used for static `style.transform`, already handling rotation/skew +/// via a four-corner AABB) picks up animated rotation too, with no +/// separate rotation-aware fold needed (constat 9). pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec { let mut violations = Vec::new(); let mut seen: HashSet<(usize, usize, String)> = HashSet::new(); + let fps = scenario.video.fps; for (vi, view) in scenario.views.iter().enumerate() { for (si, scene) in view.scenes.iter().enumerate() { + // Constat 4: same decorative-child filtering as `validate_geometry` + // — see that call site's comment for why. + let is_world = matches!(view.view_type, ViewType::World); let indexed = deserialize_children_indexed(scene); + let indexed: Vec<(usize, ChildComponent)> = if is_world { + indexed + .into_iter() + .filter(|(_, c)| !c.is_decorative()) + .collect() + } else { + indexed + }; let raw_indices: Vec = indexed.iter().map(|(i, _)| *i).collect(); let children: Vec = indexed.into_iter().map(|(_, c)| c).collect(); let viewport = (scenario.video.width, scenario.video.height); let viewport_f = (viewport.0 as f32, viewport.1 as f32); - // Use the resting layout as the base bbox. Animation transforms - // (translate/scale/wiggle/orbit) are applied analytically per - // sample — they don't reflow taffy, matching how the paint pass - // applies them after layout. - let root_css = render::root_style(scene.layout.as_ref()); - let built = build_scene_from_refs(children.iter(), viewport_f, root_css, None); - let layouts = run_layout(&built.root, viewport_f, &ConversionContext::default()); - let camera = scene .camera .as_ref() @@ -1177,11 +1263,21 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec Vec impl Iterator { + boxes + .iter() + .filter(|b| !matches!(b.kind, BoxKind::Ghost(_))) +} + #[allow(clippy::too_many_arguments)] fn walk_anim( children: &[ChildComponent], boxes: &[BoxNode], layouts: &LayoutResult, stagger_delays: &[f64], + time_params: &[(f64, f64)], viewport: (u32, u32), vi: usize, si: usize, @@ -1219,7 +1336,7 @@ fn walk_anim( out: &mut Vec, ) { let viewport_f = (viewport.0 as f32, viewport.1 as f32); - for (i, (child, box_node)) in children.iter().zip(boxes.iter()).enumerate() { + for (i, (child, box_node)) in children.iter().zip(principal_boxes(boxes)).enumerate() { let json_idx = path_indices.map(|idxs| idxs[i]).unwrap_or(i); let child_path = format!("{}.children[{}]", path, json_idx); let layout = match layouts.get(box_node.id) { @@ -1245,58 +1362,94 @@ fn walk_anim( && !parent_clips && layout.width > 0.5 && layout.height > 0.5 + // Round 4 audit, constats 2 & 9: `box_node.css` was rebuilt at + // this sample's real time (see `validate_geometry_animated`), + // so `css.opacity` already reflects `apply_animated_props` — + // no separate `AnimatedProperties` re-derivation needed for + // the visibility short-circuit any more. + && box_node.css.opacity.unwrap_or(1.0) > 0.001 { let stagger_delay = stagger_delays .get(box_node.id as usize) .copied() .unwrap_or(0.0); - // Reuse the renderer's own effect resolution instead of a - // divergent fork (H6): `effective_effects` merges timeline - // steps/synthesized transitions/stagger exactly like - // `legacy_dispatch.rs` does, and `resolve_props_for_effects` - // resolves them at absolute scene `time` — no re-timing by - // start_at, which would double-shift components that also - // declare a matching `animation` delay. + // `time` above is *global* scene time; the renderer never + // resolves effects at that raw value once a `time_scale`/ + // `time_offset`-bearing container is in the ancestor chain — + // `build_child` remaps it first (`box_builder.rs`: + // `t_local = scale * t_global + shift`), and it already used + // this same remap to build `box_node.css` above. `local_time` + // is only still needed here to independently re-derive + // `AnimatedProperties.char_animation` (char-level overshoot), + // which `apply_animated_props` deliberately does NOT bake into + // CSS (component-internal, painter-only property — see that + // function's doc comment) — `built.time_params` carries the + // exact same accumulated `(scale, shift)` the renderer used, so + // this stays in lockstep with it. + let (scale, shift) = time_params + .get(box_node.id as usize) + .copied() + .unwrap_or((1.0, 0.0)); + let local_time = scale * time + shift; let props = match effective_effects(&child.component, stagger_delay) { - Some(effects) => resolve_props_for_effects(&effects, time, scene_duration), + Some(effects) => resolve_props_for_effects(&effects, local_time, scene_duration), None => AnimatedProperties::default(), }; let raw_bbox = bbox_of(layout); - let base_bbox = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f); - if let Some(mut transformed) = transform_bbox(&base_bbox, &props) { - if let Some(cam) = camera { - transformed = fold_static_camera(&transformed, cam, viewport_f); - } - let vw = viewport.0 as f32; - let vh = viewport.1 as f32; - let eps = 0.5; - let right = transformed.x + transformed.w; - let bottom = transformed.y + transformed.h; - let x_over = transformed.x < -eps || right > vw + eps; - let y_over = transformed.y < -eps || bottom > vh + eps; - if x_over || y_over { - let axis = match (x_over, y_over) { - (true, true) => Axis::Both, - (true, false) => Axis::X, - (false, true) => Axis::Y, - _ => unreachable!(), - }; - // Dedupe across samples: one violation per (view, scene, path). - let key = (vi, si, child_path.clone()); - if seen.insert(key) { - let component_name = component_kind(&child.component).to_string(); - out.push(GeometryViolation { - view_index: vi, - scene_index: si, - path: child_path.clone(), - component: component_name, - axis, - kind: ViolationKind::AnimatedTextOverflow, - bbox: transformed, - viewport, - hint: hint_for_animated(&child.component, &props, time, scene_duration), - }); - } + // `apply_static_node_transform` — the SAME fold `walk` uses for + // a *static* `style.transform` — now does the whole job: + // `box_node.css.transform` already carries the resolved + // translate/scale/rotation (`apply_animated_props`, baked in at + // box-tree build time for this sample) composed with any + // static `style.transform` the component also declares, and + // the four-corner AABB it computes already accounts for + // rotation (constat 9) the same way it does for a static + // `transform: rotate(...)`. + let mut transformed = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f); + // Char-level overshoot (e.g. `char_scale_in`'s default 1.08) + // is the one animated-transform contributor NOT baked into + // `css.transform` — fold it in as an extra uniform scale + // around the already-transformed box's own centre. + if let Some(overshoot) = props + .char_animation + .as_ref() + .map(|c| c.overshoot.max(0.0)) + .filter(|o| *o > 1e-4) + { + transformed = scale_bbox_from_own_center(&transformed, 1.0 + overshoot); + } + if let Some(cam) = camera { + transformed = fold_static_camera(&transformed, cam, viewport_f); + } + let vw = viewport.0 as f32; + let vh = viewport.1 as f32; + let eps = 0.5; + let right = transformed.x + transformed.w; + let bottom = transformed.y + transformed.h; + let x_over = transformed.x < -eps || right > vw + eps; + let y_over = transformed.y < -eps || bottom > vh + eps; + if x_over || y_over { + let axis = match (x_over, y_over) { + (true, true) => Axis::Both, + (true, false) => Axis::X, + (false, true) => Axis::Y, + _ => unreachable!(), + }; + // Dedupe across samples: one violation per (view, scene, path). + let key = (vi, si, child_path.clone()); + if seen.insert(key) { + let component_name = component_kind(&child.component).to_string(); + out.push(GeometryViolation { + view_index: vi, + scene_index: si, + path: child_path.clone(), + component: component_name, + axis, + kind: ViolationKind::AnimatedTextOverflow, + bbox: transformed, + viewport, + hint: hint_for_animated(&child.component, &props, time, scene_duration), + }); } } } @@ -1307,6 +1460,7 @@ fn walk_anim( &box_node.children, layouts, stagger_delays, + time_params, viewport, vi, si, @@ -1323,32 +1477,21 @@ fn walk_anim( } } -/// Apply the canvas transforms the renderer applies (translate then scale -/// around the bbox center) to a base bbox. Returns `None` if the resulting -/// box is degenerate (fully transparent / zero size). -fn transform_bbox(base: &BBox, props: &AnimatedProperties) -> Option { - if props.opacity <= 0.001 { - return None; - } - // Conservative: also account for char animations that overshoot the box - // (e.g. char_scale_in defaults to 1.08). One extra factor on each axis. - let char_overshoot = props - .char_animation - .as_ref() - .map(|c| 1.0 + c.overshoot.max(0.0)) - .unwrap_or(1.0); - let sx = props.scale_x.abs().max(0.001) * char_overshoot; - let sy = props.scale_y.abs().max(0.001) * char_overshoot; - let center_x = base.x + base.w / 2.0 + props.translate_x; - let center_y = base.y + base.h / 2.0 + props.translate_y; - let new_w = base.w * sx; - let new_h = base.h * sy; - Some(BBox { - x: center_x - new_w / 2.0, - y: center_y - new_h / 2.0, - w: new_w, - h: new_h, - }) +/// Scale a bbox by `factor` around its OWN centre (as opposed to +/// `apply_static_node_transform`'s pivot, which is `transform-origin`) — +/// used only for the char-animation overshoot top-up in `walk_anim`, which +/// is not a CSS transform and has no origin concept of its own. +fn scale_bbox_from_own_center(bbox: &BBox, factor: f32) -> BBox { + let cx = bbox.x + bbox.w / 2.0; + let cy = bbox.y + bbox.h / 2.0; + let w = bbox.w * factor; + let h = bbox.h * factor; + BBox { + x: cx - w / 2.0, + y: cy - h / 2.0, + w, + h, + } } fn hint_for_animated( @@ -1447,6 +1590,59 @@ mod tests { ); } + // ─── Round 4 audit, constat 4: a `world` scene without its own `layout` + // must be validated against the SAME centred-column root layout + // `render_world_frame_scaled` synthesizes, not the plain top-aligned + // slide default ───────────────────────────────────────────────────── + + #[test] + fn layoutless_world_scene_uses_the_centred_root_not_the_slide_default() { + // A single in-flow (no `position`) 1000×100 shape, wider than the + // 800px-wide viewport, inside a `world` scene with no `layout` of + // its own. `render_world_frame_scaled` synthesizes a centred column + // (`align_items: center`) for exactly this case. + // + // Red-phase capture (root forced back to the slide default): bbox + // = {x: 0, y: 0, w: 1000, h: 100}, hint "current right edge is + // 1000" — `align_items` unset resolves start-aligned for an item + // with an explicit size, so the shape sits at x=0, right edge=1000. + // + // Under the CORRECT (world-default, centred) root, a 1000px item in + // an 800px-wide container centres at x=(800-1000)/2=-100: bbox + // x=[-100,900]. Still a single-axis (X) overflow — both edges are + // crossed, but `Axis::Both` means "X and Y both overflow", not "X + // overflows on both sides" — but the reported bbox.x is materially + // different (-100 vs 0) and, before this fix, wrong. + let json = r##"{ + "video": { "width": 800, "height": 600 }, + "composition": [{ + "type": "world", + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "shape", + "shape": "rect", + "style": { "width": "1000px", "height": "100px" }, + "fill": "#ff0000" + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + let v = violations + .iter() + .find(|v| v.kind == ViolationKind::ViewportOverflow) + .unwrap_or_else(|| panic!("expected a ViewportOverflow: {:?}", violations)); + assert_eq!(v.axis, Axis::X, "{:?}", v); + assert!( + (v.bbox.x - (-100.0)).abs() < 1.0, + "expected the shape centred at x=-100 (world root), got bbox.x={}: {:?}", + v.bbox.x, + v + ); + } + #[test] fn shape_past_right_edge_triggers_x_overflow() { // A 400×100 shape positioned at x=1700 in a 1920-wide viewport spills @@ -1525,6 +1721,81 @@ mod tests { assert_eq!(v.axis, Axis::X); } + // ─── Round 4 audit, constat 3: unwrappable_text_overflow must respect a + // clipping ancestor exactly like check_viewport/check_content_overflows_box + // already do ─────────────────────────────────────────────────────────── + + #[test] + fn unwrappable_text_is_suppressed_under_a_clipping_ancestor_card() { + // Same headline fixture as `unwrappable_text_in_narrow_card_is_flagged` + // (a 200px card, 96px nowrap text, natural width far exceeding 200px) + // but the card now clips (`overflow: hidden`): the text genuinely + // gets cropped to the card's edge at paint time, so nothing overflows + // on screen — geometry-safety.md promises this is exempt ("A node is + // also exempt when it clips itself, or when any ancestor clips it"), + // and `check_viewport`/`check_content_overflows_box` already honour + // it. This is a CORRECT scenario (the clip makes the excess + // invisible) that the validator wrongly rejected before this fix. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "card", + "x": 100, "y": 100, + "style": { "width": "200px", "height": "200px", "background": "#222244", "overflow": "hidden" }, + "children": [{ + "type": "text", + "content": "this string is too long to fit", + "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" } + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::UnwrappableTextOverflow), + "nowrap text clipped by its own card must not be flagged: {:?}", + violations + ); + } + + #[test] + fn unwrappable_text_still_fires_without_a_clipping_ancestor() { + // Regression guard: the exact pre-existing fixture from + // `unwrappable_text_in_narrow_card_is_flagged` (card overflow left + // at the default `visible`) must keep firing — the fix must only add + // a clip-aware exemption, not silence the check generally. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "card", + "x": 100, "y": 100, + "style": { "width": "200px", "height": "200px", "background": "#222244" }, + "children": [{ + "type": "text", + "content": "this string is too long to fit", + "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" } + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .any(|v| v.kind == ViolationKind::UnwrappableTextOverflow), + "must still fire when nothing clips: {:?}", + violations + ); + } + #[test] fn marquee_is_exempted_from_overflow() { // A marquee that bleeds past the viewport: no violation should fire, @@ -1581,6 +1852,141 @@ mod tests { assert_eq!(v.unwrap().component, "codeblock"); } + // ─── Round 4 audit, constat 6: check_auto_scroll must use the real + // painter's dimension formula (CodeblockIntrinsic/TerminalIntrinsic), + // not a hardcoded 16+16=32px padding assumption ────────────────────── + + #[test] + fn codeblock_auto_scroll_check_honours_explicit_padding_not_a_hardcoded_16px() { + // 10 lines, font-size defaults to 14px (line-height 1.3 -> 18.2px/line + // -> 182px of text), auto_scroll: false, box height fixed at 250px. + // style.padding is *explicitly* 60px on every side (120px vertical + // budget) — nothing close to the hardcoded "16 top + 16 bottom" the + // old formula assumed. Real natural height (chrome disabled): + // 120 (padding) + 182 (text) = 302px, ~52px past the 250px box — + // a genuine overflow. The hardcoded-32px formula computed + // 32 + 182 = 214px, comfortably under 250px, and stayed silent. + let code_lines: String = (1..=10) + .map(|i| i.to_string()) + .collect::>() + .join("\\n"); + let json = format!( + r##"{{ + "video": {{ "width": 1920, "height": 1080 }}, + "scenes": [{{ + "duration": 1.0, + "children": [{{ + "type": "codeblock", + "code": "{code_lines}", + "auto_scroll": false, + "style": {{ "width": "600px", "height": "250px", "padding": "60px" }} + }}] + }}] + }}"## + ); + let scenario = parse(&json); + let violations = validate_geometry(&scenario); + let v = violations + .iter() + .find(|v| v.kind == ViolationKind::AutoScrollDisabledOverflow); + assert!( + v.is_some(), + "expected AutoScrollDisabledOverflow for a 60px-padded codeblock the \ + hardcoded-16px formula wrongly cleared (real natural height ~302px > \ + 250px box): {:?}", + violations + ); + } + + #[test] + fn codeblock_auto_scroll_check_does_not_false_positive_on_tight_default_padding() { + // Complementary false-positive guard: 10 lines, DEFAULT padding + // (16px each side -> 32px vertical budget, matching + // CodeblockIntrinsic's own fallback for an all-zero/unset padding — + // see `CodeblockIntrinsic::from_codeblock`'s (16,16,16,16) default). + // Natural height: 32 + 182 = 214px. Box height 220px comfortably + // holds it — must NOT be flagged. + let code_lines: String = (1..=10) + .map(|i| i.to_string()) + .collect::>() + .join("\\n"); + let json = format!( + r##"{{ + "video": {{ "width": 1920, "height": 1080 }}, + "scenes": [{{ + "duration": 1.0, + "children": [{{ + "type": "codeblock", + "code": "{code_lines}", + "auto_scroll": false, + "style": {{ "width": "600px", "height": "220px" }} + }}] + }}] + }}"## + ); + let scenario = parse(&json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow), + "a codeblock that genuinely fits its box must not be flagged: {:?}", + violations + ); + } + + #[test] + fn terminal_auto_scroll_check_uses_the_painters_fixed_line_height_ratio() { + // Terminal (unlike codeblock) does NOT honour `style.line-height` at + // paint time — `terminal.rs`'s own `line_height()` method always + // computes `(font_size * 22.0 / 14.0).ceil()` (a fixed ratio baked + // into the component, `terminal::LINE_HEIGHT`/`FONT_SIZE`), ignoring + // any CSS `line-height` override entirely. The old hand-rolled check + // used `t.style.line_height_for(font_size)` (the CSS property, + // honouring `style.line-height`) instead — so a `line-height: 3` + // override (unitless -> 3 * 14px = 42px/line) inflated the OLD + // formula's estimate even though the real painter still renders + // 22px lines and ignores the override. + // + // 8 lines, chrome disabled, font-size defaults to 14: + // real (TerminalIntrinsic/painter): 2*16 (fixed padding) + + // 8 * 22 (fixed ratio, ignores the override) = 32 + 176 = 208px + // old hand-rolled (CSS line-height, AND its own wrong default + // font-size of 16px instead of the real 14px): + // 32 + 8 * line_height_for(16) = 32 + 8 * 48 = 32 + 384 = 416px + // (captured red-phase output: "terminal content needs ~416px") + // Box height fixed at 300px sits strictly between the two: the real + // content fits (208 < 300), but the old formula's inflated 416px + // wrongly reported an overflow — a false positive this fix removes. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "terminal", + "lines": [ + { "text": "one" }, { "text": "two" }, { "text": "three" }, + { "text": "four" }, { "text": "five" }, { "text": "six" }, + { "text": "seven" }, { "text": "eight" } + ], + "show_chrome": false, + "auto_scroll": false, + "style": { "width": "600px", "height": "300px", "line-height": 3 } + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow), + "terminal ignores style.line-height at paint time — the check must too, \ + real content (208px) fits the 300px box: {:?}", + violations + ); + } + // ─── C1: remediation hints must never name the nonexistent `wrap` field ── #[test] @@ -1900,10 +2306,65 @@ mod tests { } #[test] - fn strict_anim_start_at_and_effect_delay_do_not_double_shift_the_timeline() { - // A component with BOTH `start_at` (visibility gate) and a matching - // animation `delay` — a common authoring pattern ("appear and - // animate in at the same moment"). The old hand-rolled fork + fn strict_anim_respects_a_containers_time_offset_remap() { + // Constat #3: `walk_anim` used to resolve effects at raw *global* + // scene time, ignoring any `time_scale`/`time_offset` remap + // accumulated from ancestor containers — even though the renderer + // (`box_builder::build_child`) always resolves at the *local* + // remapped time (`t_local = scale * t_global + shift`). + // + // Here the shape's `slide_in_left` (delay=0, duration=1.0) sits + // inside a `flex` with `time_offset: -5.0`, which (per + // `rustmotion/src/tests.rs`'s time-remap tests) shifts local time to + // `t_local = t_global + 5.0`. Every sample in this 2s scene + // (t_global in [0, 2]) therefore resolves at local time in [5, 7] — + // 5-7s past the 1s animation window, fully settled at rest (x=100, + // well inside the 1920px-wide viewport). A walker that ignores the + // remap instead resolves at raw t_global in [0, 2], still inside the + // animation's own [0, 1] window for the first half of the scene, + // and reports a slide-in overflow that never actually happens at + // render time. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "children": [{ + "type": "flex", + "time_offset": -5.0, + "style": { "width": "1920px", "height": "1080px" }, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 100, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "slide_in_left", "delay": 0, "duration": 1.0 }] + }, + "fill": "#ff0000" + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry_animated(&scenario); + let overflow: Vec<_> = violations + .iter() + .filter(|v| v.kind == ViolationKind::AnimatedTextOverflow) + .collect(); + assert!( + overflow.is_empty(), + "time_offset=-5.0 settles the slide-in 5-7s before any sampled instant; a \ + walker that honours the remap must report zero overflows, got: {:?}", + overflow + ); + } + + #[test] + fn strict_anim_start_at_and_effect_delay_do_not_double_shift_the_timeline() { + // A component with BOTH `start_at` (visibility gate) and a matching + // animation `delay` — a common authoring pattern ("appear and + // animate in at the same moment"). The old hand-rolled fork // re-based time by subtracting start_at *again* before resolving // the preset, which is already absolute-time-shifted by its own // `delay`. That double shift pushed every sample right after @@ -1976,6 +2437,169 @@ mod tests { ); } + // ─── Round 4 audit, constat 2: --strict-anim must resolve `timeline` + // style states and audio-reactive transforms — both gated on + // `local_actx.is_some()` in `build_child`, which was always `None` + // here ──────────────────────────────────────────────────────────────── + + #[test] + fn strict_anim_catches_a_brief_excursion_a_40_sample_cap_would_miss() { + // A 100×100 shape resting safely at x=100 (box x=[100,200]) in a + // 20s scene, with `slide_in_left` (delay=9.85s, duration=1.0s): + // `position.x` eases from -200 to 0 via EaseOutCubic, so the box + // only crosses the left edge (x < -0.5) for the FIRST ~20% of the + // 1s window (t in [9.85, ~10.06]) — a ~206ms excursion, while + // opacity has already ramped past its own [9.85, 10.15] fade-in + // window's midpoint by then (so it isn't filtered as invisible). + // + // With the OLD `ANIM_MAX_SAMPLES=40` cap, this 20s scene sampled at + // step 20/39 ≈ 0.513s — grid points at k·0.513s land at + // t=9.744 (k=19) and t=10.256 (k=20), straddling the whole ~206ms + // excursion without a single sample landing inside it. Confirmed by + // temporarily reverting the cap to 40 during development: this + // fixture produced ZERO violations (`violations: []`) — the exact + // false negative constat 8 describes. + // + // With the new 480-sample cap (step ≈ 0.042s), a sample lands well + // inside the excursion — this run finds one at t=9.94s with + // bbox.x≈-52.16 (tx≈-152 relative to the resting x=100). + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 20.0, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 490, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "slide_in_left", "delay": 9.85, "duration": 1.0 }] + }, + "fill": "#ff0000" + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry_animated(&scenario); + let v = violations + .iter() + .find(|v| v.kind == ViolationKind::AnimatedTextOverflow) + .unwrap_or_else(|| { + panic!( + "expected the ~206ms slide_in_left excursion around t≈9.85-10.06s \ + to be caught by the denser sampling: {:?}", + violations + ) + }); + assert_eq!(v.component, "shape"); + assert_eq!(v.axis, Axis::X); + assert!( + v.bbox.x < -0.5, + "expected a negative bbox.x (left-edge overflow), got {}", + v.bbox.x + ); + } + + #[test] + fn strict_anim_detects_a_timeline_width_step_that_overflows_later_in_the_scene() { + // A 200×100 shape, safely inside a 1920×1080 viewport at rest + // (x=[100,300]). A `timeline` step at t=1.0s grows `style.width` to + // 1900px — box_builder's `apply_style_states` runs on the CSS + // *before* layout, so this is a genuine box-model change, not a + // paint-only transform: at t>=1.0s the real render lays out a + // 1900px-wide box at x=100, right edge 2000 — 80px past the + // 1920px-wide frame. + // + // The OLD `--strict-anim` walker built its box tree ONCE with + // `anim: None` (so `apply_style_states` only ever evaluated at + // t=0, before the step's `at`) and never rebuilt it per sample — + // every one of the 16 samples in this 2s scene measured the + // resting 200px-wide box, so this never got flagged. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 100, + "style": { "width": "200px", "height": "100px" }, + "timeline": [{ "at": 1.0, "style": { "width": "1900px" } }], + "fill": "#ff0000" + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry_animated(&scenario); + let v = violations + .iter() + .find(|v| v.kind == ViolationKind::AnimatedTextOverflow) + .unwrap_or_else(|| { + panic!( + "expected AnimatedTextOverflow once the t=1.0s timeline step widens \ + the box to 1900px: {:?}", + violations + ) + }); + assert_eq!(v.component, "shape"); + assert_eq!(v.axis, Axis::X); + } + + // ─── Round 4 audit, constat 9: --strict-anim must model rotation (and + // any other transform `apply_animated_props` bakes into `css.transform`), + // not just translate_x/y and scale_x/y ───────────────────────────────── + + #[test] + fn strict_anim_detects_a_spin_animation_pushing_a_square_off_screen() { + // Same headline numbers as the static-transform regression + // `static_rotation_is_folded_into_the_viewport_check`: a 100×100 + // square at (1810, 490) in a 1920×1080 viewport — resting box + // x=[1810,1910], 10px inside the right edge. `spin` animates + // `rotation` linearly 0deg->360deg over the 2s scene; ANY sampled + // angle away from a multiple of 90deg grows the AABB half-width + // beyond 50px * (|cos|+|sin|) > 50px, pushing the right edge past + // 1920 (e.g. at 20deg: half-width ~64.1px, right edge ~1924). + // + // The OLD `transform_bbox` only read `translate_x/y`/`scale_x/y` + // from `AnimatedProperties` — a pure-rotation preset leaves both at + // their identity values (0 and 1), so every sample folded to + // exactly the resting bbox and this never fired, at any sample, + // for the whole 2s sweep through 360 degrees. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 1810, "y": 490, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "spin", "delay": 0, "duration": 2.0 }] + }, + "fill": "#ff0000" + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry_animated(&scenario); + let v = violations + .iter() + .find(|v| v.kind == ViolationKind::AnimatedTextOverflow) + .unwrap_or_else(|| { + panic!( + "expected AnimatedTextOverflow from the spin preset rotating the \ + square past the right edge at some sampled angle: {:?}", + violations + ) + }); + assert_eq!(v.component, "shape"); + assert_eq!(v.axis, Axis::X); + } + #[test] fn anim_sample_times_scale_with_scene_duration() { let short = anim_sample_times(0.5); @@ -2000,6 +2624,59 @@ mod tests { ); } + #[test] + fn anim_sample_times_keeps_the_8_per_second_cadence_up_to_60s() { + // Round 4 audit, constat 8: with the old ANIM_MAX_SAMPLES=40 cap, + // the step between samples grew linearly past a 5s scene — + // 20s/39 ≈ 0.513s at 20s, 40s/39 ≈ 1.026s at 40s, 60s/39 ≈ 1.538s + // at 60s (the exact numbers the audit cited). With the raised cap + // (480), the step stays pinned near the promised 1/8s = 0.125s + // resolution across the same range. + for duration in [20.0, 40.0, 60.0] { + let samples = anim_sample_times(duration); + let step = duration / (samples.len() - 1) as f64; + assert!( + (step - 0.125).abs() < 0.001, + "duration={duration}s: expected ~0.125s step, got {step}s ({} samples)", + samples.len() + ); + } + } + + #[test] + #[ignore = "manual timing probe for constat 8's cap — not run in CI"] + fn timing_probe_for_constat_8() { + let mut children = String::new(); + for i in 0..15 { + children.push_str(&format!( + r##"{{"type":"text","position":"absolute","x":{},"y":{}, + "content":"item {}", + "style":{{"font-size":32,"color":"#ffffff", + "animation":[{{"name":"slide_in_left","delay":0.1,"duration":0.5}}]}}}},"##, + (i % 5) * 300, + (i / 5) * 200, + i + )); + } + children.pop(); // trailing comma + let json = format!( + r##"{{"video":{{"width":1920,"height":1080}}, + "scenes":[{{"duration":60.0,"children":[{children}]}}]}}"## + ); + let scenario = parse(&json); + let n = anim_sample_times(60.0).len(); + let start = std::time::Instant::now(); + let violations = validate_geometry_animated(&scenario); + let elapsed = start.elapsed(); + eprintln!( + "timing_probe: {} samples, {:?} total, {:?}/sample, {} violations", + n, + elapsed, + elapsed / n.max(1) as u32, + violations.len() + ); + } + // ─── H4 (second half): content larger than its own content box ─────────── // // The first half of H4 (already fixed above) suppresses a *viewport* @@ -2314,6 +2991,96 @@ mod tests { assert_eq!(v.axis, Axis::X, "only the x-axis should overflow: {:?}", v); } + // ─── Round 4 audit, constat 5: `transform-origin` must pivot the static + // transform fold, not always the box centre ───────────────────────────── + + #[test] + fn transform_origin_right_edge_keeps_a_scaled_shape_inside_the_viewport() { + // 100×100 shape at (880, 450) in a 1000×1000 viewport: at rest, + // x=[880,980] — comfortably inside, 20px margin. `scale(x: 3)` + // pivoted at `transform-origin: { x: "right" }` (the box's own right + // edge, 100%) grows the box purely leftward from that fixed edge: + // left corner offset from pivot (980) is -100, ×3 = -300 -> new x = + // 680; right corner offset is 0 -> stays at 980. Correct AABB: + // x=[680,980], fully inside [0,1000] — this scenario is CORRECT. + // + // Before this fix, `apply_static_node_transform` always pivoted at + // the box centre (930): left corner offset -50×3=-150 -> x=780; + // right corner offset +50×3=+150 -> x=1080 — 80px past the 1000-wide + // frame, a false positive (captured in the red-phase run below). + let json = r##"{ + "video": { "width": 1000, "height": 1000 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 880, "y": 450, + "style": { + "width": "100px", "height": "100px", + "transform": [{ "fn": "scale", "x": 3, "y": 1 }], + "transform-origin": { "x": "right" } + }, + "fill": "#ff0000" + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::ViewportOverflow), + "transform-origin: right should pivot growth away from the right \ + edge, keeping the shape inside the 1000px-wide viewport: {:?}", + violations + ); + } + + #[test] + fn transform_origin_50pct_is_identical_to_the_default_centre_pivot() { + // Sanity/regression guard: an *explicit* `transform-origin: 50% 50%` + // must fold to exactly the same AABB as no `transform-origin` at all + // — same fixture and expectation as + // `static_rotation_is_folded_into_the_viewport_check`. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 1810, "y": 490, + "style": { + "width": "100px", "height": "100px", + "transform": [{ "fn": "rotate", "deg": 45 }], + "transform-origin": { "x": "50%", "y": "50%" } + }, + "fill": "#ff0000" + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + let v = violations + .iter() + .find(|v| v.component == "shape" && v.kind == ViolationKind::ViewportOverflow) + .unwrap_or_else(|| { + panic!( + "explicit 50%/50% origin must behave like the default centre pivot: {:?}", + violations + ) + }); + assert_eq!(v.axis, Axis::X); + assert!( + (v.bbox.x + v.bbox.w - 1930.71).abs() < 1.0, + "right edge should land at the same ~1930.7 as the centre-pivot case: {:?}", + v + ); + } + #[test] fn unrotated_transform_folding_is_unchanged_by_the_corner_based_rewrite() { // Regression guard for the H5 rewrite: a translate-only transform @@ -2378,17 +3145,32 @@ mod tests { assert_eq!(v.axis, Axis::Y); } - // ─── #128 item 2: component overflowing its containing card ────────────── + // ─── Round 4 audit, constat 7: `ContentOverflowsCard` (#128 item 2) is + // retired — a component escaping a non-clipping (`overflow: visible`, + // the default) card is the exact "badge sticking out of a card is + // legal" pattern CLAUDE.md / geometry-safety.md document as fine, so the + // validator must accept it, not report it. See the module doc comment's + // "Deliberately NOT in scope" note and `walk`'s retired call site for + // the full reasoning. These fixtures are the same ones that used to + // assert the (wrong) opposite — kept, with flipped assertions, as + // regression coverage across component types (text/codeblock/table/ + // nested card/bleed) now that the check is gone. ───────────────────── #[test] - fn absolutely_positioned_text_spilling_past_its_card_is_flagged() { - // The audit's headline repro: a text with no fixed height, inside a - // card, grows to its natural (unclamped) size because it's taken - // out of flex flow (`position: absolute`) — its OWN box already - // matches its OWN content exactly (so `ContentOverflowsBox` must NOT - // fire), yet that box spills well past the 80px-tall card it lives - // in. Before #128 item 2, nothing ever compared a component to the - // card containing it, so this validated clean. + fn absolutely_positioned_text_spilling_past_a_visible_card_is_legal() { + // The audit's original headline repro for #128 item 2: a text with + // no fixed height, inside a card, grows to its natural (unclamped) + // size because it's taken out of flex flow (`position: absolute`) + // — its OWN box already matches its OWN content exactly (so + // `ContentOverflowsBox` must NOT fire), and that box spills past + // the 80px-tall card it lives in. The card's `overflow` is + // `visible` (the documented default that permits exactly this) — + // per constat 7, `ContentOverflowsCard` must no longer fire here. + // + // Red-phase (before this fix): validate_geometry reported one + // ContentOverflowsCard violation for this fixture (component: + // "text", axis: Y, hint mentioning "extends past its containing + // card") — captured when this test asserted the opposite. let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, "scenes":[{"duration":1.0,"children":[ {"type":"card","position":"absolute","x":330,"y":100, @@ -2403,7 +3185,7 @@ mod tests { violations .iter() .all(|v| v.kind != ViolationKind::ContentOverflowsBox), - "the text's own box already matches its own (unclamped) content — must not also fire ContentOverflowsBox: {:?}", + "the text's own box already matches its own (unclamped) content — must not fire ContentOverflowsBox: {:?}", violations ); assert!( @@ -2413,26 +3195,41 @@ mod tests { "fixture should stay inside the 540px-tall frame by construction: {:?}", violations ); - let v = violations - .iter() - .find(|v| v.kind == ViolationKind::ContentOverflowsCard) - .unwrap_or_else(|| { - panic!( - "expected ContentOverflowsCard for text spilling past its 80px card: {:?}", - violations - ) - }); - assert_eq!(v.component, "text"); - assert_eq!( - v.axis, - Axis::Y, - "card is wide enough — only height should overflow: {:?}", - v + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::ContentOverflowsCard), + "text sticking out of a visible-overflow card is a legal, documented pattern: {:?}", + violations ); + } + + /// The guarantee that makes retiring `ContentOverflowsCard` safe rather + /// than merely defensible: escaping a visible card is legal, but + /// escaping the *device* never is, and that is `check_viewport`'s job — + /// not the retired check's. Same fixture as + /// `absolutely_positioned_text_spilling_past_a_visible_card_is_legal`, + /// moved down the frame so the overspill leaves the viewport. If this + /// ever stops firing, the removal has opened a real blind spot. + #[test] + fn spilling_past_a_visible_card_is_still_caught_when_it_leaves_the_viewport() { + let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, + "scenes":[{"duration":1.0,"children":[ + {"type":"card","position":"absolute","x":330,"y":460, + "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, + "children":[{"type":"text","position":"absolute","x":0,"y":0, + "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", + "style":{"font-size":44,"color":"#ffffff","width":"300px"}}]}]}]}"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( - v.hint.contains("card"), - "hint should mention the card: {}", - v.hint + violations + .iter() + .any(|v| v.kind == ViolationKind::ViewportOverflow), + "content escaping a visible card AND the 540px frame must still be reported by \ + check_viewport — retiring ContentOverflowsCard must not have removed this: {:?}", + violations ); } @@ -2483,16 +3280,16 @@ mod tests { } #[test] - fn absolutely_positioned_codeblock_spilling_past_its_card_is_flagged() { + fn absolutely_positioned_codeblock_spilling_past_a_visible_card_is_legal() { // #128 item 1's first repro ("a codeblock painting 578px inside a // 300px card"): taken out of flex flow (`position: absolute`, like // the analogous text/table tests above) so its own box is NOT // shrunk to fit the card — it stays at its natural, unscrolled // content height regardless of `auto_scroll`. `auto_scroll: true` - // (the default) is used deliberately here: `check_auto_scroll` only - // ever fires for `auto_scroll: false`, so this proves the - // card-relative check is a genuinely independent, complementary - // mechanism, not a duplicate of it. + // (the default) is used deliberately here so `check_auto_scroll` + // stays quiet too, isolating this from every other check: the card + // has default (`visible`) overflow, so per constat 7 this must + // validate clean. let code_lines: String = (1..=30) .map(|i| i.to_string()) .collect::>() @@ -2533,16 +3330,13 @@ mod tests { "fixture should stay inside the 1080px-tall frame by construction: {:?}", violations ); - let v = violations - .iter() - .find(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "codeblock") - .unwrap_or_else(|| { - panic!( - "expected ContentOverflowsCard for a 30-line codeblock spilling past its 300px card: {:?}", - violations - ) - }); - assert_eq!(v.axis, Axis::Y); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::ContentOverflowsCard), + "codeblock sticking out of a visible-overflow card is a legal, documented pattern: {:?}", + violations + ); } #[test] @@ -2553,9 +3347,7 @@ mod tests { // assigned box down to the card's 60px (same shrink-to-fit behaviour // already established for `text`), so this surfaces via the // generalized `ContentOverflowsBox` (own box too small for own - // content) rather than the card check — see - // `absolutely_positioned_table_spilling_past_its_card_is_flagged` - // below for the complementary (unclamped, card-relative) case. + // content). let rows: String = (1..=15) .map(|i| format!(r#"["{i}a","{i}b","{i}c"]"#)) .collect::>() @@ -2594,12 +3386,11 @@ mod tests { } #[test] - fn absolutely_positioned_table_spilling_past_its_card_is_flagged() { + fn absolutely_positioned_table_spilling_past_a_visible_card_is_legal() { // Same table, but taken out of flex flow (`position: absolute`) so - // its own box isn't shrunk to fit the card — mirrors - // `absolutely_positioned_text_spilling_past_its_card_is_flagged`, - // isolating the card-relative check (item 2) from the own-box check - // (item 1) exercised above. + // its own box isn't shrunk to fit the card — the card's `overflow` + // is `visible` (the default), so per constat 7 this must validate + // clean. let rows: String = (1..=15) .map(|i| format!(r#"["{i}a","{i}b","{i}c"]"#)) .collect::>() @@ -2627,75 +3418,21 @@ mod tests { ); let scenario = parse(&json); let violations = validate_geometry(&scenario); - let v = violations - .iter() - .find(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "table") - .unwrap_or_else(|| { - panic!( - "expected ContentOverflowsCard for an absolutely positioned table spilling past its 60px card: {:?}", - violations - ) - }); - assert_eq!(v.axis, Axis::Y); - } - - #[test] - fn nested_non_card_container_still_compares_its_descendant_to_the_outer_card() { - // A `container` (layout-only, no decoration per CLAUDE.md) sits - // between the card and the codeblock. `container`/`flex`/`grid`/ - // `positioned` must NOT update `nearest_card` — only `Component::Card` - // does — so the codeblock two levels down must still be compared - // against the *outer* card, not silently un-checked. - let code_lines: String = (1..=30) - .map(|i| i.to_string()) - .collect::>() - .join("\\n"); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "card", - "position": "absolute", - "x": 100, "y": 100, - "style": {{ "width": "600px", "height": "300px", "background": "#111111" }}, - "children": [{{ - "type": "container", - "children": [{{ - "type": "codeblock", - "position": "absolute", - "x": 0, "y": 0, - "code": "{code_lines}" - }}] - }}] - }}] - }}] - }}"## - ); - let scenario = parse(&json); - let violations = validate_geometry(&scenario); - let v = violations - .iter() - .find(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "codeblock") - .unwrap_or_else(|| { - panic!( - "expected ContentOverflowsCard through the non-card `container` wrapper: {:?}", - violations - ) - }); assert!( - v.path.contains("children[0].children[0].children[0]"), - "path should reach through card -> container -> codeblock: {}", - v.path + violations + .iter() + .all(|v| v.kind != ViolationKind::ContentOverflowsCard), + "table sticking out of a visible-overflow card is a legal, documented pattern: {:?}", + violations ); } #[test] - fn nested_card_bigger_than_its_outer_card_is_flagged() { - // A card nested inside another card, itself bigger than the outer - // one it lives in — the inner card's own box (not just its - // descendants') must be checked against the outer card too. + fn nested_card_bigger_than_its_visible_outer_card_is_legal() { + // A card nested inside another (default/`visible`-overflow) card, + // itself bigger than the outer one it lives in — per constat 7 this + // is the same "sticking out on purpose" pattern, now legal for any + // component type, cards included. let json = r##"{ "video": { "width": 1920, "height": 1080 }, "scenes": [{ @@ -2718,19 +3455,23 @@ mod tests { assert!( violations .iter() - .any(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "card"), - "the inner card's own box must be checked against the outer card: {:?}", + .all(|v| v.kind != ViolationKind::ContentOverflowsCard), + "an inner card bigger than its visible-overflow outer card is legal: {:?}", violations ); } #[test] - fn content_overflowing_its_card_is_suppressed_under_a_clipping_card() { - // Same headline repro as `absolutely_positioned_text_spilling_past_its_card_is_flagged`, - // but the card clips (`overflow: hidden`) — the text genuinely gets - // clipped at paint time, so ContentOverflowsCard must not fire, - // consistent with the `parent_clips` suppression already applied to - // check_viewport/check_content_overflows_box. + fn content_overflowing_a_clipping_card_is_also_not_flagged() { + // Same headline repro, but the card clips (`overflow: hidden`) — + // the text genuinely gets clipped at paint time, so + // ContentOverflowsCard must not fire either, consistent with the + // `parent_clips` suppression already applied to + // check_viewport/check_content_overflows_box. Distinct from the + // `visible` fixtures above: this is the OTHER half of the "no + // configuration where it's both reachable and correct to fire" + // argument (constat 7) — a clipping card suppresses it for an + // unrelated reason (parent_clips), not because of the retirement. let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, "scenes":[{"duration":1.0,"children":[ {"type":"card","position":"absolute","x":330,"y":100, @@ -2749,31 +3490,6 @@ mod tests { ); } - #[test] - fn bleed_true_does_not_exempt_content_overflows_card() { - // Same headline repro, `bleed: true` added to the text. Per the - // frozen `bleed` contract (see `bleeds`'s doc comment): a component - // may leave the *frame* on purpose and still be responsible for its - // own contents relative to its card — `bleed` must not exempt this - // check any more than it exempts ContentOverflowsBox. - let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, - "scenes":[{"duration":1.0,"children":[ - {"type":"card","position":"absolute","x":330,"y":100, - "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, - "children":[{"type":"text","position":"absolute","x":0,"y":0,"bleed":true, - "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", - "style":{"font-size":44,"color":"#ffffff","width":"300px"}}]}]}]}"##; - let scenario = parse(json); - let violations = validate_geometry(&scenario); - assert!( - violations - .iter() - .any(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "text"), - "bleed: true must NOT suppress ContentOverflowsCard: {:?}", - violations - ); - } - #[test] fn component_that_fits_its_card_is_not_flagged() { // Passing-case guard: same shape as the codeblock repro, but the diff --git a/crates/rustmotion-cli/src/commands/still.rs b/crates/rustmotion-cli/src/commands/still.rs index 8da5c13..df2f9ec 100644 --- a/crates/rustmotion-cli/src/commands/still.rs +++ b/crates/rustmotion-cli/src/commands/still.rs @@ -1,7 +1,51 @@ +use rustmotion::encode; use rustmotion::engine; use rustmotion::error::{Result, RustmotionError}; use rustmotion::schema::ResolvedScenario; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// A scratch path in the same directory as `output`, carrying the same +/// extension so extension-sniffing encoders (the `image::save` fallback arm) +/// still resolve the codec they would have resolved for `output` itself. +/// +/// Constat #8: `File::create(output)` used to run *before* the encoder that +/// could fail (JPEG always failed on the RGBA buffer), so every failure left +/// a 0-byte file sitting at `output` — indistinguishable from a real, empty +/// render to a downstream script. Encoding into this scratch path first and +/// renaming onto `output` only on success means a failure never touches +/// `output` at all: an old file there is left untouched, and no new +/// truncated file appears. +fn temp_sibling_path(output: &Path) -> PathBuf { + let ext = output.extension().and_then(|e| e.to_str()); + let stem = output + .file_stem() + .and_then(|e| e.to_str()) + .unwrap_or("still"); + let name = match ext { + Some(ext) => format!(".{stem}.rustmotion-tmp.{ext}"), + None => format!(".{stem}.rustmotion-tmp"), + }; + output.with_file_name(name) +} + +/// Flatten RGBA onto an opaque background for encoders that cannot +/// represent alpha (JPEG). Compositing onto `video.background` — the color +/// the frame actually renders against — rather than dropping the alpha +/// channel outright (which would implicitly composite onto black). +fn flatten_to_rgb(img: &image::RgbaImage, bg: (u8, u8, u8)) -> Vec { + let (bg_r, bg_g, bg_b) = bg; + let mut rgb = Vec::with_capacity(img.as_raw().len() / 4 * 3); + for px in img.pixels() { + let [r, g, b, a] = px.0; + let a = a as u16; + let inv_a = 255 - a; + let blend = |fg: u8, bg: u8| -> u8 { ((fg as u16 * a + bg as u16 * inv_a) / 255) as u8 }; + rgb.push(blend(r, bg_r)); + rgb.push(blend(g, bg_g)); + rgb.push(blend(b, bg_b)); + } + rgb +} pub fn cmd_still( scenario: ResolvedScenario, @@ -18,70 +62,251 @@ pub fn cmd_still( let config = &scenario.video; let fps = config.fps; - // Find which scene contains this time - let all_scenes: Vec<_> = scenario.all_scenes().collect(); - let mut scene_start = 0.0f64; - for (idx, scene) in all_scenes.iter().enumerate() { - let scene_end = scene_start + scene.duration; - if time < scene_end || idx == all_scenes.len() - 1 { - let local_time = (time - scene_start).max(0.0); - let frame_index = (local_time * fps as f64).round() as u32; - let scene_frames = (scene.duration * fps as f64).round() as u32; - - let rgba = engine::render::render_scene_frame( - config, - scene, - frame_index.min(scene_frames.saturating_sub(1)), - scene_frames, - )?; - - // Create parent directories - if let Some(parent) = output.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - } + // Constat #4: pick the frame the same way the encoder does. Summing + // scene durations linearly (the previous approach) ignores that + // `build_frame_tasks` truncates the entering scene's tail to make room + // for a transition's overlap — so `--time` landed on a frame that never + // appears, composited or otherwise, in the rendered video. Reusing + // `build_frame_tasks` + `render_frame_task_scaled` also picks up + // `apply_post_effects` (vignette, grain, ...) for free, which the old + // per-scene walk never applied at all. + let tasks = encode::build_frame_tasks(&scenario); + let total = tasks.len() as u32; + if total == 0 { + return Err(RustmotionError::NoFrames); + } + + // Preserve the previous command's tolerant behavior: negative time + // clamps to frame 0, time beyond the video's duration clamps to the + // last frame, instead of erroring. + let raw_index = (time.max(0.0) * fps as f64).round(); + let frame_index = if raw_index.is_finite() { + (raw_index as i64).clamp(0, total as i64 - 1) as u32 + } else { + 0 + }; + + let task = &tasks[frame_index as usize]; + let rgba = encode::render_frame_task_scaled(config, &scenario, task, 1.0)?; + + // Create parent directories + if let Some(parent) = output.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } - let img = image::RgbaImage::from_raw(config.width, config.height, rgba) - .ok_or(RustmotionError::PixelImage)?; - - let fmt = format - .as_deref() - .unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("png")); - - match fmt { - "jpeg" | "jpg" => { - use image::ImageEncoder; - let file = std::fs::File::create(output)?; - let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality); - encoder.write_image( - img.as_raw(), - config.width, - config.height, - image::ExtendedColorType::Rgba8, - )?; - } - "webp" => { - use image::ImageEncoder; - let file = std::fs::File::create(output)?; - let encoder = image::codecs::webp::WebPEncoder::new_lossless(file); - encoder.write_image( - img.as_raw(), - config.width, - config.height, - image::ExtendedColorType::Rgba8, - )?; - } - _ => { - img.save(output)?; - } + let img = image::RgbaImage::from_raw(config.width, config.height, rgba) + .ok_or(RustmotionError::PixelImage)?; + + let fmt = format + .as_deref() + .unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("png")); + + let tmp_path = temp_sibling_path(output); + let encode_result: std::result::Result<(), RustmotionError> = (|| { + match fmt { + "jpeg" | "jpg" => { + use image::ImageEncoder; + let (bg_r, bg_g, bg_b, _) = engine::parse_hex_color(&config.background); + let rgb = flatten_to_rgb(&img, (bg_r, bg_g, bg_b)); + let file = std::fs::File::create(&tmp_path)?; + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality); + encoder.write_image( + &rgb, + config.width, + config.height, + image::ExtendedColorType::Rgb8, + )?; + } + "webp" => { + use image::ImageEncoder; + let file = std::fs::File::create(&tmp_path)?; + let encoder = image::codecs::webp::WebPEncoder::new_lossless(file); + encoder.write_image( + img.as_raw(), + config.width, + config.height, + image::ExtendedColorType::Rgba8, + )?; } + _ => { + img.save(&tmp_path)?; + } + } + Ok(()) + })(); - eprintln!("Still image saved to {}", output.display()); - return Ok(()); + match encode_result { + Ok(()) => { + std::fs::rename(&tmp_path, output)?; + } + Err(e) => { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); } - scene_start = scene_end; } - Err(RustmotionError::TimeOutOfRange { time }) + eprintln!("Still image saved to {}", output.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion::loader::load_scenario_from_source; + + fn solid_rect_scenario(width: u32, height: u32, fps: u32, duration: f64, hex: &str) -> String { + format!( + r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}}, + "scenes": [{{"duration": {duration}, "children": [ + {{"type": "shape", "shape": "rect", "fill": "{hex}", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}}]}}"## + ) + } + + fn minimal_scenario(width: u32, height: u32, fps: u32, duration: f64) -> ResolvedScenario { + let json = solid_rect_scenario(width, height, fps, duration, "#ff0000"); + load_scenario_from_source(None, Some(&json)).expect("load") + } + + /// `name` (e.g. "still.jpg") must stay the *last* path component so its + /// extension survives — putting the uniqueness suffix after it (as an + /// earlier version of this helper did) turned "still.jpg" into + /// "still.jpg_1234_5678", whose "extension" per `Path::extension()` + /// becomes "jpg_1234_5678": unrecognized by every format-sniffing + /// encoder, so every test using it failed on a spurious + /// `Unsupported(PathExtension(..))` instead of exercising the code + /// under test at all. + fn scratch_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rm_still_test_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + name + )) + } + + /// Constat #8: JPEG stills used to fail unconditionally (the `image` + /// crate's JPEG encoder rejects `Rgba8`) and leave a 0-byte file behind. + #[test] + fn still_jpeg_encodes_successfully_and_writes_a_nonempty_valid_file() { + let scenario = minimal_scenario(16, 16, 10, 1.0); + let out = scratch_path("jpeg_ok.jpg"); + let _ = std::fs::remove_file(&out); + + cmd_still(scenario, &out, 0.0, None, 90).expect("jpeg still must succeed"); + + let meta = std::fs::metadata(&out).expect("output file must exist"); + assert!(meta.len() > 0, "jpeg output must not be empty"); + let img = image::open(&out).expect("must decode as a valid image"); + assert_eq!(img.width(), 16); + assert_eq!(img.height(), 16); + + let _ = std::fs::remove_file(&out); + } + + /// Constat #8 (aggravation noted by the verification pass): `--format + /// jpeg` must succeed regardless of the output path's own extension. + #[test] + fn still_format_flag_forces_jpeg_even_with_a_png_extension() { + let scenario = minimal_scenario(16, 16, 10, 1.0); + let out = scratch_path("forced.png"); + let _ = std::fs::remove_file(&out); + + cmd_still(scenario, &out, 0.0, Some("jpeg".to_string()), 90) + .expect("forced jpeg still must succeed"); + + let meta = std::fs::metadata(&out).expect("output file must exist"); + assert!(meta.len() > 0, "forced jpeg output must not be empty"); + + let _ = std::fs::remove_file(&out); + } + + /// `temp_sibling_path` is the mechanism constat #8's "never leave a + /// truncated file" fix relies on: encode into a scratch path first, + /// rename onto `output` only on success. Lock in its naming contract — + /// distinct from `output`, same directory (so the later rename is a + /// same-filesystem, near-atomic op), extension preserved so + /// extension-sniffing encoders still resolve the right codec. + #[test] + fn temp_sibling_path_is_distinct_same_directory_and_keeps_the_extension() { + let output = PathBuf::from("/some/dir/still.jpg"); + let tmp = temp_sibling_path(&output); + + assert_ne!( + tmp, output, + "scratch path must not collide with the final output path" + ); + assert_eq!( + tmp.parent(), + output.parent(), + "scratch path must live in the same directory as output (same filesystem for rename)" + ); + assert_eq!( + tmp.extension().and_then(|e| e.to_str()), + Some("jpg"), + "scratch path must keep output's extension for format-sniffing encoders" + ); + } + + /// Constat #4: `still --time` must match the frame the encoder actually + /// emits at that timestamp, not a linear per-scene walk that ignores + /// transition overlap. Scene A (2s) + scene B (2s, incoming 1s fade): + /// the rendered stream truncates scene A's tail by 1s, so `--time 2.5` + /// must resolve to the composited transition frame at global index + /// round(2.5 * fps), the same index `build_frame_tasks` would hand the + /// encoder — not scene B's raw, uncomposited frame at local time 0.5s. + #[test] + fn still_time_matches_the_encoders_frame_stream_across_a_transition() { + let fps = 30u32; + let json = format!( + r##"{{ + "video": {{"width": 8, "height": 8, "fps": {fps}}}, + "scenes": [ + {{"duration": 2.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#ff0000", + "position": "absolute", "x": 0, "y": 0, "style": {{"width": 8, "height": 8}}}} + ]}}, + {{"duration": 2.0, "transition": {{"type": "fade", "duration": 1.0}}, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#0000ff", + "position": "absolute", "x": 0, "y": 0, "style": {{"width": 8, "height": 8}}}} + ]}} + ] + }}"## + ); + // `ResolvedScenario` isn't `Clone`, and `cmd_still` takes it by + // value — load it twice from the same JSON instead. + let scenario = load_scenario_from_source(None, Some(&json)).expect("load"); + let scenario_for_expected = load_scenario_from_source(None, Some(&json)).expect("load"); + + let out = scratch_path("transition.png"); + let _ = std::fs::remove_file(&out); + cmd_still(scenario, &out, 2.5, None, 90).expect("still must succeed"); + let still_img = image::open(&out).expect("decode still").to_rgba8(); + + let tasks = encode::build_frame_tasks(&scenario_for_expected); + let frame_index = (2.5_f64 * fps as f64).round() as usize; + let expected_rgba = encode::render_frame_task_scaled( + &scenario_for_expected.video, + &scenario_for_expected, + &tasks[frame_index], + 1.0, + ) + .expect("render expected frame"); + let expected_img = image::RgbaImage::from_raw(8, 8, expected_rgba).unwrap(); + + assert_eq!( + still_img.as_raw(), + expected_img.as_raw(), + "still --time must match the encoder's frame stream, not a linear scene-boundary walk" + ); + + let _ = std::fs::remove_file(&out); + } } diff --git a/crates/rustmotion-cli/src/commands/validate.rs b/crates/rustmotion-cli/src/commands/validate.rs index a5f65f6..28d0abe 100644 --- a/crates/rustmotion-cli/src/commands/validate.rs +++ b/crates/rustmotion-cli/src/commands/validate.rs @@ -27,6 +27,67 @@ fn announced_duration(scenario: &ResolvedScenario) -> f64 { rustmotion::encode::build_frame_tasks(scenario).len() as f64 / fps } +/// Why `--fix` must not write over this input. +/// +/// `--fix` serialises `LoadedScenario::raw`, which is the document *after* +/// variable substitution and `include` resolution — not the document on disk. For +/// a plain JSON scenario the two coincide and writing back is faithful. For +/// anything templated they do not, and the write silently replaces the source +/// with its own expansion: the `config` block and every `$var` disappear, includes +/// get inlined into the parent, and an HTML input is replaced by JSON outright. +/// +/// One rule covers all three: only write back a source `--fix` can reproduce. +#[derive(Debug, PartialEq, Eq)] +enum FixRefusal { + HtmlSource, + Templated, + UsesInclude, +} + +impl FixRefusal { + fn explain(&self, path: &Path) -> String { + let p = path.display(); + match self { + Self::HtmlSource => format!( + "--fix cannot rewrite {p}: it is an HTML source, and the fixer only knows how to \ + emit JSON — applying it would replace your markup with the transpiled scenario. \ + Apply the fix to the HTML by hand, or transpile first and fix the JSON." + ), + Self::Templated => format!( + "--fix cannot rewrite {p}: it declares `config` or uses `$variables`, and the \ + fixer would write back the substituted scenario — dropping the template and \ + making `--var` a silent no-op. Fix the template by hand." + ), + Self::UsesInclude => format!( + "--fix cannot rewrite {p}: it uses `include`, and the fixer would write back the \ + resolved tree — inlining the included files into the parent and patching by a \ + path that no longer means the same node. Fix the included file directly." + ), + } + } +} + +/// `None` when `--fix` may write over `input`. +fn refuse_fix(input: &Path, raw_source: &str) -> Option { + if rustmotion::loader::is_html_path(input) { + return Some(FixRefusal::HtmlSource); + } + // Inspect the bytes on disk, not the loaded tree: by then substitution has + // already erased the very markers that make the write unfaithful. + let source: serde_json::Value = match serde_json::from_str(raw_source) { + Ok(v) => v, + // Unparseable source is not something we should be overwriting either. + Err(_) => return Some(FixRefusal::Templated), + }; + if source.get("config").is_some() || raw_source.contains("$") { + return Some(FixRefusal::Templated); + } + if raw_source.contains("\"include\"") { + return Some(FixRefusal::UsesInclude); + } + None +} + pub fn cmd_validate( input: &PathBuf, report: Option<&Path>, @@ -57,6 +118,10 @@ pub fn cmd_validate( let mut applied_fixes = 0usize; if fix && !report_out.geom_violations.is_empty() { + let raw_source = std::fs::read_to_string(input).unwrap_or_default(); + if let Some(refusal) = refuse_fix(input, &raw_source) { + return Err(RustmotionError::Generic(refusal.explain(input))); + } let mut json_value = loaded.raw.clone(); applied_fixes = apply_fixes(&mut json_value, &report_out.geom_violations); if applied_fixes > 0 { @@ -431,4 +496,212 @@ mod tests { "expected 3.0s with no transitions, got {duration}" ); } + + /// `--fix` writes back the *resolved* tree. Anything the resolution erased is + /// erased on disk too, so these three inputs must be refused rather than + /// silently rewritten. + mod fix_refusals { + use super::super::{refuse_fix, FixRefusal}; + use std::path::Path; + + const PLAIN: &str = r#"{"video":{"width":320,"height":240,"fps":30}, + "scenes":[{"duration":1.0,"children":[]}]}"#; + + #[test] + fn a_plain_json_scenario_is_writable() { + assert_eq!(refuse_fix(Path::new("s.json"), PLAIN), None); + } + + #[test] + fn an_html_source_is_refused() { + // Writing here replaces the author's markup with transpiled JSON. + assert_eq!( + refuse_fix(Path::new("s.html"), ""), + Some(FixRefusal::HtmlSource) + ); + } + + #[test] + fn a_templated_scenario_is_refused() { + // The write would bake in the substitution and make --var a no-op. + let with_config = r#"{"config":{"title":"hi"},"video":{"width":320,"height":240, + "fps":30},"scenes":[{"duration":1.0,"children":[]}]}"#; + assert_eq!( + refuse_fix(Path::new("s.json"), with_config), + Some(FixRefusal::Templated) + ); + + let with_var = r#"{"video":{"width":320,"height":240,"fps":30}, + "scenes":[{"duration":1.0,"children":[ + {"type":"text","content":"$title"}]}]}"#; + assert_eq!( + refuse_fix(Path::new("s.json"), with_var), + Some(FixRefusal::Templated) + ); + } + + #[test] + fn a_scenario_using_include_is_refused() { + // The resolved tree inlines the include, so a path-based patch lands on + // a node the source file does not contain. + let with_include = r#"{"video":{"width":320,"height":240,"fps":30}, + "scenes":[{"include":"part.json"}]}"#; + assert_eq!( + refuse_fix(Path::new("s.json"), with_include), + Some(FixRefusal::UsesInclude) + ); + } + + #[test] + fn every_refusal_names_the_file_and_says_what_to_do_instead() { + let p = Path::new("scenes/hero.json"); + for r in [ + FixRefusal::HtmlSource, + FixRefusal::Templated, + FixRefusal::UsesInclude, + ] { + let msg = r.explain(p); + assert!(msg.contains("scenes/hero.json"), "{msg}"); + assert!(msg.contains("by hand") || msg.contains("directly"), "{msg}"); + } + } + } + + /// Round 4 audit, constat 1: PR #145 introduced `refuse_fix`, gated on + /// the raw bytes on disk (not `loaded.raw`), and `apply_fixes`/`navigate` + /// already walk raw-preserving indices (H3, see + /// `geometry.rs::deserialize_children_indexed`'s doc comment). This + /// workstream's job is not to redo that fix — it's to prove, end to + /// end through `cmd_validate` (not just unit-testing `refuse_fix` in + /// isolation, as `fix_refusals` above does), that the refusal actually + /// engages for the two concrete failure modes constat 1 names: + /// - validate.rs:60 — `--fix` would otherwise serialise the + /// *post-substitution* document, dropping `config` and baking in + /// `$var` resolutions, silently destroying the template. + /// - validate.rs:198 — a violation path carries the *resolved* scene + /// index (post `include::resolve_entries` inlining), which does not + /// line up with the RAW `scenes` array `navigate` walks as soon as an + /// `include` expands to a scene count that shifts later positions. + /// + /// Both are already covered by the existing `refuse_fix` gate (a + /// `Templated`/`UsesInclude` scenario is refused outright, before + /// `apply_fixes` ever runs) — these two tests are the proof, not a new + /// fix. No RED phase: this constat is "verify existing behaviour", not + /// "here is a bug"; both tests pass on first run. + mod fix_refusals_end_to_end { + use super::super::cmd_validate; + + #[test] + fn cmd_validate_fix_refuses_to_overwrite_a_templated_scenario_and_leaves_the_file_untouched( + ) { + let path = std::env::temp_dir().join(format!( + "rm_validate_fix_templated_{}.json", + std::process::id() + )); + // `config` + a whole-string `$title` reference, plus a real + // geometry violation (nowrap text far too wide for its card) so + // `--fix` actually attempts to write. + let original = r##"{ + "config": { "title": { "type": "string", "default": "hi" } }, + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "card", + "x": 100, "y": 100, + "style": { "width": "200px", "height": "200px", "background": "#222244" }, + "children": [{ + "type": "text", + "content": "$title but also this string is too long to fit", + "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" } + }] + }] + }] + }"##; + std::fs::write(&path, original).expect("write fixture"); + + let result = cmd_validate(&path, None, /*fix=*/ true, false, false, false, None); + + let after = std::fs::read_to_string(&path).expect("read back fixture"); + std::fs::remove_file(&path).ok(); + + assert!( + result.is_err(), + "--fix on a templated scenario with a real violation must be refused, \ + not silently applied" + ); + assert_eq!( + after, original, + "the file must be byte-identical after a refused --fix — writing \ + loaded.raw here would have dropped `config` and baked in the \ + substituted $title" + ); + } + + #[test] + fn cmd_validate_fix_refuses_to_overwrite_a_scenario_using_include_and_leaves_files_untouched( + ) { + let dir = std::env::temp_dir() + .join(format!("rm_validate_fix_include_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + let part_path = dir.join("part.json"); + let parent_path = dir.join("parent.json"); + + // The included file resolves to TWO scenes; the offending + // narrow-card/nowrap-text violation lives in the SECOND one, so + // its *resolved* scene index (1) does not correspond to any + // scene in the parent's own RAW `scenes` array (which has a + // single entry: the include directive) — the concrete index + // skew constat 1 names. + let part = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [ + { "duration": 1.0, "children": [] }, + { + "duration": 1.0, + "children": [{ + "type": "card", + "x": 100, "y": 100, + "style": { "width": "200px", "height": "200px", "background": "#222244" }, + "children": [{ + "type": "text", + "content": "this string is too long to fit", + "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" } + }] + }] + } + ] + }"##; + let parent = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ "include": "part.json" }] + }"##; + std::fs::write(&part_path, part).expect("write part fixture"); + std::fs::write(&parent_path, parent).expect("write parent fixture"); + + let result = cmd_validate( + &parent_path, + None, + /*fix=*/ true, + false, + false, + false, + None, + ); + + let parent_after = std::fs::read_to_string(&parent_path).expect("read back parent"); + let part_after = std::fs::read_to_string(&part_path).expect("read back part"); + std::fs::remove_dir_all(&dir).ok(); + + assert!( + result.is_err(), + "--fix on an include-using scenario with a real violation must be refused" + ); + assert_eq!( + parent_after, parent, + "parent file must be byte-identical after a refused --fix" + ); + assert_eq!(part_after, part, "included file must be untouched too"); + } + } } diff --git a/crates/rustmotion-cli/src/commands/validate_attrs.rs b/crates/rustmotion-cli/src/commands/validate_attrs.rs index 16d5d26..ce85c47 100644 --- a/crates/rustmotion-cli/src/commands/validate_attrs.rs +++ b/crates/rustmotion-cli/src/commands/validate_attrs.rs @@ -351,4 +351,65 @@ mod tests { assert_eq!(errors.len(), 1, "expected one error: {errors:?}"); assert!(errors[0].contains("counter"), "got: {}", errors[0]); } + + #[test] + fn typo_inside_style_animation_effect_is_reported() { + // Constat #8: `walk_component` only compares a component's own + // top-level keys, then recurses into `children` — it never looks + // inside `style`, let alone `style.animation[*]`. A typo'd field on + // an animation effect (`duratoin` instead of `duration`) used to + // deserialize silently (the effect config structs had no + // `deny_unknown_fields`), so the author got a scenario that "worked" + // but quietly ran the default 0.8s duration instead of theirs. + // + // The fix lives in `schema/video.rs` (adding `deny_unknown_fields` to + // every `AnimationEffect` payload struct) rather than here: an + // internally-tagged enum's tag field is excluded from what the + // variant's own `Deserialize` sees, so this rejects the typo without + // ever flagging the legitimate `name` tag as unknown. That routes the + // typo through the *existing* typed-parse-failure path in + // `check_component_attrs` (the same one that already catches, e.g., + // a missing required field) — it surfaces as a blocking error, not a + // `walk_component` warning. + let s = resolved(serde_json::json!([ + { + "type": "text", "content": "hi", + "style": { "animation": [{ "name": "fade_in_up", "duratoin": 0.6 }] } + } + ])); + let (errors, _) = check_component_attrs(&s); + assert!( + errors.iter().any(|e| e.contains("duratoin")), + "expected the typo'd animation-effect field to be reported as an error: {errors:?}" + ); + } + + #[test] + fn well_formed_animation_effect_fields_are_not_flagged() { + // Sanity companion to the typo test: legitimate fields across a + // spread of effect kinds (preset timing, keyframes, wiggle, glow, + // motion_blur, tilt_in) must not trip the new deny_unknown_fields. + let s = resolved(serde_json::json!([ + { + "type": "text", "content": "hi", + "style": { "animation": [ + { "name": "fade_in_up", "delay": 0.2, "duration": 0.6, "loop": false, + "overshoot": 0.1, "spring": { "damping": 12, "stiffness": 100, "mass": 1 } }, + { "name": "float_3d", "duration": 1.0, "amplitude": 20 }, + { "name": "tilt_in", "delay": 0.0, "duration": 0.4, "rotate_x": 10.0 }, + { "name": "wiggle", "property": "translate_y", "amplitude": 5, "frequency": 2, "seed": 3 }, + { "name": "glow", "color": "#ffffff", "radius": 10, "intensity": 1.0 }, + { "name": "motion_blur", "samples": 4, "shutter": 0.5 }, + { "name": "keyframes", "keyframes": [ + { "property": "opacity", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 } + ] } + ], "delay": 0.0, "duration": 1.0, "loop": true } + ] } + } + ])); + let (errors, warnings) = check_component_attrs(&s); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + } } diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index 5b116b1..6b30b5b 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -5,7 +5,7 @@ use rustmotion::components::{ChildComponent, Component}; use rustmotion::core::css::style::{ Background, BackgroundLayer, Color, CssStyle, Display as CssDisplay, }; -use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario}; +use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario, SpringConfig}; pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec) { let mut errors = Vec::new(); @@ -123,27 +123,52 @@ fn validate_children( } // Animation completion budget check: ensure entrance animations finish within the scene. + // + // Constat #4: `start_at` is a *visibility* window, not a time + // origin — the engine resolves `animation.delay`/`duration` in + // absolute scene time regardless of `start_at` (PR #27's frozen + // semantics; `geometry.rs`'s `walk_anim` already states and relies + // on the same rule). The budget used to add `start_at` in here, + // which contradicts that: a scenario where the entrance genuinely + // finishes well inside the scene (just before the node becomes + // visible, so it appears already-settled) was rejected as if the + // animation ran late. if let Some(anim) = child.component.as_animatable() { - let start_at = child - .component - .as_timed() - .and_then(|t| t.timing().0) - .unwrap_or(0.0); - for effect in anim.animation_effects() { if let Some((delay, duration)) = entrance_budget(effect) { - let finishes_at = start_at + delay + duration; + let finishes_at = delay + duration; // 50ms tolerance for floating-point edge cases. if finishes_at > scene_duration + 0.05 { let suggested = ((finishes_at + 0.5) * 10.0).ceil() / 10.0; errors.push(format!( - "{}: animation finishes at {:.2}s (start_at {:.2} + delay {:.2} + duration {:.2}) \ + "{}: animation finishes at {:.2}s (delay {:.2} + duration {:.2}) \ but scene_duration is {:.2}s — it will be truncated. \ Increase scene duration to at least {:.1}s or reduce animation delay/duration.", - p, finishes_at, start_at, delay, duration, scene_duration, suggested + p, finishes_at, delay, duration, scene_duration, suggested )); } } + + // Constat #6: `SpringConfig` accepts any f64 unchecked — a + // preset's own `spring` override (`AnimationTiming::spring`, + // reachable via `as_preset()`) or a `keyframes` effect's + // per-`Animation` `spring` (used when that segment's easing + // is `spring`) both feed `engine::animator::spring_value`, + // where `mass <= 0`/`stiffness <= 0` produce NaN and negative + // `damping` diverges. Reject both regimes here so a bad + // config never reaches the solver. + if let Some((_, timing)) = effect.as_preset() { + if let Some(spring) = &timing.spring { + check_spring_config(spring, &p, errors); + } + } + if let AnimationEffect::Keyframes(k) = effect { + for kf_anim in &k.keyframes { + if let Some(spring) = &kf_anim.spring { + check_spring_config(spring, &p, errors); + } + } + } } } @@ -347,6 +372,36 @@ fn check_color_str(s: &str, label: &str, path: &str, errors: &mut Vec) { } } +/// Constat #6: reject `SpringConfig` values that would make +/// `engine::animator::spring_value` produce NaN (`mass <= 0`, `stiffness <= +/// 0`) or diverge instead of settle (`damping < 0`). The solver itself also +/// floors these defensively (belt and suspenders — see `spring_value`'s doc +/// comment), but catching it here gives the author an actionable error +/// instead of a silently broken render. +fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec) { + if spring.mass <= 0.0 { + errors.push(format!( + "{path}: spring.mass must be > 0 (got {}) — zero or negative mass makes the spring \ + solver divide by zero and produce NaN", + spring.mass + )); + } + if spring.stiffness <= 0.0 { + errors.push(format!( + "{path}: spring.stiffness must be > 0 (got {}) — zero or negative stiffness makes \ + the spring solver produce NaN", + spring.stiffness + )); + } + if spring.damping < 0.0 { + errors.push(format!( + "{path}: spring.damping must be >= 0 (got {}) — negative damping makes the spring \ + diverge instead of settle", + spring.damping + )); + } +} + /// The `time_scale` declared on a container component, if any. fn container_time_scale(component: &Component) -> Option { match component { @@ -554,6 +609,113 @@ mod style_warning_tests { ); } + #[test] + fn negative_spring_damping_is_an_error() { + // Constat #6: damping < 0 makes the spring solver diverge instead of + // settle (a `SpringConfig` accepts any f64 — nothing in + // `rustmotion-cli` checked `damping`/`stiffness` before this). + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": -5, "stiffness": 100, "mass": 1 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("damping")), + "missing spring damping error: {errors:?}" + ); + } + + #[test] + fn zero_spring_stiffness_is_an_error() { + // stiffness <= 0 makes `spring_value`'s omega = sqrt(stiffness/mass) NaN. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "bounce_in", "duration": 0.6, "spring": { "damping": 10, "stiffness": 0, "mass": 1 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("stiffness")), + "missing spring stiffness error: {errors:?}" + ); + } + + #[test] + fn zero_spring_mass_is_an_error() { + // mass <= 0 makes omega = sqrt(stiffness/mass) divide by zero -> NaN. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 10, "stiffness": 100, "mass": 0 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("mass")), + "missing spring mass error: {errors:?}" + ); + } + + #[test] + fn spring_inside_a_keyframes_effect_is_also_checked() { + // Springs aren't only on presets: a `keyframes` effect's per-Animation + // `spring` field feeds the exact same solver. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ + "name": "keyframes", + "keyframes": [{ + "property": "scale", + "easing": "spring", + "spring": { "damping": -1, "stiffness": 100, "mass": 1 }, + "keyframes": [{ "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }] + }] + }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("damping")), + "missing spring damping error inside a keyframes effect: {errors:?}" + ); + } + + #[test] + fn positive_spring_values_are_accepted() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + } + #[test] fn positive_time_scale_is_accepted() { let child: ChildComponent = serde_json::from_value(serde_json::json!({ @@ -568,6 +730,37 @@ mod style_warning_tests { validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); } + + #[test] + fn completion_budget_does_not_add_start_at_to_delay_plus_duration() { + // Constat #4: `start_at` gates *visibility* only (PR #27) — the + // engine resolves `animation.delay`/`duration` in absolute scene + // time regardless of `start_at`, so an entrance that finishes at + // delay+duration=1.0s in a 2.0s scene is fine even if the node isn't + // visible until start_at=1.5s (it simply appears already-settled). + // The old formula added them (`start_at + delay + duration` = + // 1.5+0+1.0 = 2.5 > 2.0), rejecting this valid scenario. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 100, + "start_at": 1.5, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "slide_in_left", "delay": 0.0, "duration": 1.0 }] + }, + "fill": "#ff0000" + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 2.0, &mut errors, &mut warnings); + assert!( + errors.iter().all(|e| !e.contains("animation finishes")), + "start_at must not be added to the completion budget: {errors:?}" + ); + } } /// C2 completion (issue #110 / #102): an unresolved colour must fail diff --git a/crates/rustmotion-cli/src/lib.rs b/crates/rustmotion-cli/src/lib.rs index 013f489..be15b12 100644 --- a/crates/rustmotion-cli/src/lib.rs +++ b/crates/rustmotion-cli/src/lib.rs @@ -1,3 +1,4 @@ +mod claude_md; mod commands; mod skills; pub mod tui; diff --git a/crates/rustmotion-cli/src/skills.rs b/crates/rustmotion-cli/src/skills.rs index 33c1306..1267d67 100644 --- a/crates/rustmotion-cli/src/skills.rs +++ b/crates/rustmotion-cli/src/skills.rs @@ -192,10 +192,13 @@ pub fn install(global: bool) -> Result<()> { } } - // Write CLAUDE.md only in local mode + // Write CLAUDE.md only in local mode. The project may already own this file — + // merge into a delimited block rather than claiming the whole document. if !global { let claude_path = root.join("CLAUDE.md"); - if write_if_changed(&claude_path, CLAUDE_MD)? { + let existing = std::fs::read_to_string(&claude_path).ok(); + let merged = crate::claude_md::merge(existing.as_deref(), CLAUDE_MD); + if write_if_changed(&claude_path, &merged)? { written += 1; } else { skipped += 1; @@ -299,12 +302,24 @@ pub fn uninstall(global: bool) -> Result<()> { std::fs::remove_dir_all(&skills_dir)?; let mut removed = 1; - // Remove CLAUDE.md only in local mode + // Remove only what we put there. A CLAUDE.md carrying the project's own + // instructions keeps them; the file is deleted only when our block was all it + // ever held. if !global { let claude_path = root.join("CLAUDE.md"); - if claude_path.exists() { - std::fs::remove_file(&claude_path)?; - removed += 1; + if let Ok(existing) = std::fs::read_to_string(&claude_path) { + match crate::claude_md::strip(&existing) { + Some(remaining) => { + if remaining != existing { + std::fs::write(&claude_path, remaining)?; + removed += 1; + } + } + None => { + std::fs::remove_file(&claude_path)?; + removed += 1; + } + } } } diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index f1f0780..2ec06c0 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -142,6 +142,7 @@ where format!("/children/{i}"), 0.0, (1.0, 0.0), + &root_css, )); } @@ -226,6 +227,7 @@ fn build_ghosts<'a>( stagger_delay: f64, time_remap: (f64, f64), effects: &[AnimationEffect], + parent_css: &CssStyle, ) -> Vec { let (mb, tr) = detect_ghost_effects(effects); @@ -274,6 +276,9 @@ fn build_ghosts<'a>( if let Some(z) = child.z_index { css.z_index = Some(z); } + // Cascade: a ghost is the same component as the principal, painted + // at a different sampled time, so it inherits from the same parent. + rustmotion_core::css::cascade::inherit_from(parent_css, &mut css); // Apply timeline style states at the ghost time. if let Some(animatable) = child.component.as_animatable() { let steps = animatable.timeline_steps(); @@ -399,6 +404,7 @@ fn build_child<'a>( path: String, stagger_delay: f64, time_remap: (f64, f64), + parent_css: &CssStyle, ) -> Vec { // Compute the local animation context for this node — remapped by the // accumulated affine time transform from ancestor containers. @@ -428,6 +434,7 @@ fn build_child<'a>( stagger_delay, time_remap, &effects, + parent_css, ); } } @@ -450,6 +457,15 @@ fn build_child<'a>( css.z_index = Some(z); } + // CSS cascade (round 4 audit, lot LAYOUT, constat 2): propagate + // inheritable properties (color, font-*, text-align, white-space, ...) + // from the parent's already-cascaded style into any of this node's own + // unset properties — mirrors CSS's "specified value" resolution, which + // happens before state/animation overrides compute the final value. + // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but + // nothing called it until this fix. + rustmotion_core::css::cascade::inherit_from(parent_css, &mut css); + // Timeline style states: merge every state whose (at + stagger) <= t // into the box CSS. Opacity is excluded when a `transition` smooths it // (the synthesized keyframes then own its whole history). States affect @@ -569,6 +585,7 @@ fn build_child<'a>( &path, stagger_delay, time_remap, + &css, ); let intrinsic = component_intrinsic(&child.component); @@ -908,6 +925,7 @@ fn container_children<'a>( parent_path: &str, inherited_delay: f64, time_remap: (f64, f64), + parent_css: &CssStyle, ) -> Vec { let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option, f64, f64) = match component { @@ -973,6 +991,7 @@ fn container_children<'a>( format!("{parent_path}/children/{j}"), inherited_delay + j as f64 * step, child_remap, + parent_css, )); } result @@ -1305,6 +1324,14 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } + // ── Round 4 audit, lot LAYOUT, constat 4: the 23-components block + // below (`Callout` through `Lottie`) now routes every default size + // through `apply_default_size`, which honours an explicit + // `aspect-ratio` (see its own doc comment) instead of the two + // guards below reaching separate, aspect-ratio-blind defaults — + // `width: 400` + `aspect-ratio: 16/9` used to still get the + // component's unrelated hardcoded default height (e.g. `shape`'s + // 80px) instead of the 225px the ratio implies. // ── #126 / W3: the 23 components with no size source ───────────── // // A card's default flex column gives every child its width via @@ -1345,12 +1372,11 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0), CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size), }; - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(text_w + h_pad * 2.0 + extra_w))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(line_h + v_pad + extra_h))); - } + apply_default_size( + css, + text_w + h_pad * 2.0 + extra_w, + line_h + v_pad + extra_h, + ); } Tooltip(t) => { // Same shape as Callout above; padding value borrowed from @@ -1368,12 +1394,11 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { (0.0, t.arrow_size) } }; - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(text_w + h_pad * 2.0 + extra_w))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(line_h + v_pad + extra_h))); - } + apply_default_size( + css, + text_w + h_pad * 2.0 + extra_w, + line_h + v_pad + extra_h, + ); } PillNav(p) => { // `height` is already a declared field on the component (like @@ -1382,24 +1407,17 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // formula (h_pad = font_size*1.2 per side, `gap` before/after/ // between every pill) using the same public fields and the same // `measure_text_with_fallback` call it makes internally. - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(p.height))); - } - if css.width.is_none() { - let font_size = p.style.font_size_px_or(14.0); - let family = p.style.font_family_or("Inter"); - let h_pad = font_size * 1.2; - let n = p.items.len() as f32; - let labels_w: f32 = p - .items - .iter() - .map(|label| { - measure_text_line_width(label, font_size, family, false) + h_pad * 2.0 - }) - .sum(); - let total_w = labels_w + p.gap * (n + 1.0).max(1.0); - css.width = Some(CSize::Length(CLP::Px(total_w))); - } + let font_size = p.style.font_size_px_or(14.0); + let family = p.style.font_family_or("Inter"); + let h_pad = font_size * 1.2; + let n = p.items.len() as f32; + let labels_w: f32 = p + .items + .iter() + .map(|label| measure_text_line_width(label, font_size, family, false) + h_pad * 2.0) + .sum(); + let total_w = labels_w + p.gap * (n + 1.0).max(1.0); + apply_default_size(css, total_w, p.height); } Marquee(m) => { // Marquee's whole purpose is to scroll unbounded content, so @@ -1418,12 +1436,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // `font_size: 24` paired with `style.height: 48`, i.e. // `2 × font_size`. let font_size = m.style.font_size_px_or(m.font_size); - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(800.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(font_size * 2.0))); - } + apply_default_size(css, 800.0, font_size * 2.0); } Stepper(s) => { // Same shape as `Timeline`'s formula above (r*2 + label metrics), @@ -1448,36 +1461,26 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { .fold(0.0_f32, f32::max); match s.orientation { StepperOrientation::Horizontal => { - if css.width.is_none() { - // Per-step allocation: the node needs ~3 diameters of - // breathing room (a common stepper-UI spacing - // convention), or enough for its longest label/desc, - // whichever is larger. - let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0); - css.width = Some(CSize::Length(CLP::Px(per_step * n))); - } - if css.height.is_none() { - let label_h = LABEL_FS * 1.3; - let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 }; - let h = s.node_size + 4.0 + 12.0 + label_h + desc_h; - css.height = Some(CSize::Length(CLP::Px(h))); - } + // Per-step allocation: the node needs ~3 diameters of + // breathing room (a common stepper-UI spacing + // convention), or enough for its longest label/desc, + // whichever is larger. + let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0); + let label_h = LABEL_FS * 1.3; + let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 }; + let h = s.node_size + 4.0 + 12.0 + label_h + desc_h; + apply_default_size(css, per_step * n, h); } StepperOrientation::Vertical => { - if css.width.is_none() { - let label_w = max_label_w.max(max_desc_w); - let w = s.node_size + 12.0 + label_w + 24.0; - css.width = Some(CSize::Length(CLP::Px(w))); - } - if css.height.is_none() { - let label_block = if has_desc { - LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0 - } else { - LABEL_FS * 1.3 + 8.0 - }; - let per_step = (s.node_size * 2.0).max(label_block); - css.height = Some(CSize::Length(CLP::Px(per_step * n))); - } + let label_w = max_label_w.max(max_desc_w); + let w = s.node_size + 12.0 + label_w + 24.0; + let label_block = if has_desc { + LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0 + } else { + LABEL_FS * 1.3 + 8.0 + }; + let per_step = (s.node_size * 2.0).max(label_block); + apply_default_size(css, w, per_step * n); } } } @@ -1512,12 +1515,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { let lines = (total_w / box_w).ceil().max(1.0); let line_h = tc.max_font_size * 1.3; let box_h = lines * line_h + (lines - 1.0).max(0.0) * V_GAP; - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(box_w))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(box_h))); - } + apply_default_size(css, box_w, box_h); } } Heatmap(h) => { @@ -1528,25 +1526,15 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { let rows = h.data.len(); let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0); let step = h.cell_size + h.cell_gap; - if css.width.is_none() { - let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size; - css.width = Some(CSize::Length(CLP::Px(w))); - } - if css.height.is_none() { - let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size; - css.height = Some(CSize::Length(CLP::Px(hh))); - } + let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size; + let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size; + apply_default_size(css, w, hh); } Sparkline(_) => { // "Sparkline: no axes, no labels, compact (120x40 default), // inline use" — documented in // .claude/skills/rustmotion/rules/data-viz-components.md. - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(120.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(40.0))); - } + apply_default_size(css, 120.0, 40.0); } Stat(_) => { // Documented default from @@ -1555,12 +1543,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // the fix for the issue's second bug: three `stat`s in a flex // row with no explicit size rendered zero pixels because width // (not just height) collapsed to 0 in a row context. - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(280.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(180.0))); - } + apply_default_size(css, 280.0, 180.0); } Gauge(g) => { // Square — gauge.rs's own paint() derives its ring radius from @@ -1575,12 +1558,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // icon-sizing-hierarchy.md. const TARGET_RADIUS: f32 = 88.0; let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0); - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(size))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(size))); - } + apply_default_size(css, size, size); } DotMap(_) => { // 2:1 — the standard aspect ratio for an equirectangular world @@ -1588,24 +1566,14 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // dot_map.rs's own `geo_to_screen` implements. dot_map.rs always // paints a full-box background rect first, so any positive size // shows ink even with zero points. - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(640.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(320.0))); - } + apply_default_size(css, 640.0, 320.0); } Comparison(_) => { // No natural intrinsic size (the painter just splits whatever // box it's given at the divider) — matches this project's own // reference usage in examples/mega-showcase.json's `comparison` // block. - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(520.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(280.0))); - } + apply_default_size(css, 520.0, 280.0); } Treemap(_) => { // Slice-and-dice treemap fills whatever box it's given — matches @@ -1613,12 +1581,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // examples/mega-showcase.json's `treemap` block (near-square, // the conventional treemap aspect since its rectangles are area- // proportional in both axes). - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(416.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(368.0))); - } + apply_default_size(css, 416.0, 368.0); } Chart(c) => { // Pie/donut/radar/radial_bar are inherently circular — a square @@ -1631,12 +1594,12 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { c.chart_type, ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar ); - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(if round { 320.0 } else { 400.0 }))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(if round { 320.0 } else { 300.0 }))); - } + let (dw, dh) = if round { + (320.0, 320.0) + } else { + (400.0, 300.0) + }; + apply_default_size(css, dw, dh); } Skeleton(s) => { // `rectangle`: documented default from data-viz-components.md's @@ -1648,31 +1611,12 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // formula above — matches skeleton.rs's own per-line paint loop // (`y = i * (line_height + line_gap)`) exactly. match s.variant { - SkeletonVariant::Rectangle => { - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(400.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(200.0))); - } - } - SkeletonVariant::Circle => { - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(64.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(64.0))); - } - } + SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0), + SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0), SkeletonVariant::Text => { let n = s.lines.max(1) as f32; - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(240.0))); - } - if css.height.is_none() { - let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap; - css.height = Some(CSize::Length(CLP::Px(h))); - } + let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap; + apply_default_size(css, 240.0, h); } } } @@ -1686,12 +1630,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { MockupDevice::Laptop => (640.0, 400.0), MockupDevice::Browser => (640.0, 360.0), }; - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(dw))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(dh))); - } + apply_default_size(css, dw, dh); } Icon(_) => { // 64×64 — the midpoint of the documented "card / feature icon" @@ -1699,12 +1638,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // icon-sizing-hierarchy.md (desktop 40–56px, mobile 72–96px, // square 60–80px), and a size icon asset systems near-universally // ship as a default export (24/32/48/64 being the common family). - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(64.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(64.0))); - } + apply_default_size(css, 64.0, 64.0); } Svg(_) => { // 200×200 — square, since an arbitrary vector graphic (icon, @@ -1712,12 +1646,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // common equal-aspect SVG viewBox convention and sits above // Icon's 64px "card icon" role for the more elaborate content // `svg` typically carries (illustrations/diagrams, not glyphs). - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(200.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(200.0))); - } + apply_default_size(css, 200.0, 200.0); } Shape(_) => { // 80×80 — matches the median of this project's own decorative @@ -1726,51 +1655,92 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // (26, 36, 44, 60, 70, 140 — median ~55, rounded up for // visibility as a standalone default rather than a same-scene // accent tuned against neighbours). - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(80.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(80.0))); - } + apply_default_size(css, 80.0, 80.0); } Image(_) => { // 4:3 (400×300) — the traditional default photo aspect ratio, // distinct from Video/Gif's 16:9 below so a generic still image // doesn't presume widescreen framing. - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(400.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(300.0))); - } + apply_default_size(css, 400.0, 300.0); } Video(_) | Gif(_) => { // 16:9 (400×225) — the industry-standard video aspect ratio // (matches every render resolution this project documents: // 1920×1080, 1280×720), scaled down to a card-sized default. - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(400.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(225.0))); - } + apply_default_size(css, 400.0, 225.0); } Lottie(_) => { // 300×300 — square, matching the aspect the vast majority of // Lottie animation assets ship at (LottieFiles' own marketplace // preview convention is a 1:1 canvas). - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(300.0))); - } - if css.height.is_none() { - css.height = Some(CSize::Length(CLP::Px(300.0))); - } + apply_default_size(css, 300.0, 300.0); } _ => {} } } +/// Apply a component's natural default size (`dw` × `dh`) to `css`, honouring +/// an explicit `aspect-ratio` instead of always falling back to `dw`/`dh` +/// independently (round 4 audit, lot LAYOUT, constat 4 — the previous code +/// guarded each axis with its own `is_none()` check and never looked at +/// `aspect-ratio`, so `width: 400` + `aspect-ratio: 16/9` still got the +/// component's unrelated hardcoded default height instead of 225). +/// +/// - Both axes already set: untouched (the author fully specified the box). +/// - One axis set to a fixed pixel length, `aspect-ratio` present: the other +/// axis is derived from it (`h = w / ratio` or `w = h * ratio`) — the CSS +/// replaced-element sizing rule for a single definite axis plus a +/// preferred aspect ratio. +/// - Neither axis set: the natural default width is kept (there is no +/// author-declared axis to derive from), and height is derived from +/// `aspect-ratio` when present, the natural default height otherwise. +/// +/// `min-*`/`max-*` need no equivalent guard here: taffy clamps the final +/// used size against them at layout time regardless of what `size` resolves +/// to (`style.min_size`/`max_size` in `taffy_bridge::to_taffy_style`), so a +/// default below `min-width` is corrected downstream, not silently wrong. +fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) { + let ratio = css.aspect_ratio.filter(|r| *r > 0.0); + match (css.width.is_some(), css.height.is_some()) { + (true, true) => {} + (true, false) => { + let h = fixed_px(css.width.as_ref()) + .zip(ratio) + .map(|(w, r)| w / r) + .unwrap_or(dh); + css.height = Some(CSize::Length(CLP::Px(h))); + } + (false, true) => { + let w = fixed_px(css.height.as_ref()) + .zip(ratio) + .map(|(h, r)| h * r) + .unwrap_or(dw); + css.width = Some(CSize::Length(CLP::Px(w))); + } + (false, false) => { + css.width = Some(CSize::Length(CLP::Px(dw))); + let h = ratio.map(|r| dw / r).unwrap_or(dh); + css.height = Some(CSize::Length(CLP::Px(h))); + } + } +} + +/// Extract a fixed pixel value from a `Size`, if it resolves to one without a +/// `LengthContext` (only `Size::Length(LengthPercentage::Px(_))` — a bare +/// number or `"NNpx"`). `%`/`vw`/`vh`/`em`/`rem` and `auto` return `None`: +/// `apply_default_size` can't derive a ratio from a length it can't resolve +/// at build time, so it falls back to the component's hardcoded default. +fn fixed_px(size: Option<&CSize>) -> Option { + match size? { + CSize::Length(lp) => match lp.try_parse()? { + rustmotion_core::css::units::ParsedLength::Px(v) => Some(v), + _ => None, + }, + _ => None, + } +} + /// Borrow the `CssStyle` from any component. fn component_style(c: &Component) -> &CssStyle { use Component::*; @@ -1947,6 +1917,27 @@ mod tests { } } + fn make_text(content: &str, style: CssStyle) -> ChildComponent { + ChildComponent { + component: Component::Text(crate::text::Text { + content: content.to_string(), + max_width: None, + timing: Default::default(), + style, + timeline: Vec::new(), + stagger: None, + text_shadow: None, + stroke: None, + text_background: None, + }), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + } + } + #[test] fn empty_scene_has_only_root() { let built = build_scene(&[], (1920.0, 1080.0)); @@ -2752,4 +2743,192 @@ mod tests { ); } } + + // ── Round 4 audit, lot LAYOUT, constat 2: the CSS cascade is wired ────── + // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but + // nothing called it — `color`/`font-*` set on a container never reached + // children lacking their own value. + + #[test] + fn card_color_cascades_to_text_child_with_no_color_of_its_own() { + use rustmotion_core::css::style::Color; + + let card = make_card( + vec![make_text("hello", CssStyle::default())], + CssStyle { + color: Some(Color::String("#ff0000".into())), + ..Default::default() + }, + ); + let scene = vec![ChildComponent { + component: card, + position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }]; + let built = build_scene(&scene, (800.0, 600.0)); + let text_box = &built.root.children[0].children[0]; + assert_eq!( + text_box.css.color, + Some(Color::String("#ff0000".into())), + "text child declares no color of its own — it should inherit the card's" + ); + } + + #[test] + fn text_own_color_wins_over_inherited_card_color() { + use rustmotion_core::css::style::Color; + + let card = make_card( + vec![make_text( + "hello", + CssStyle { + color: Some(Color::String("#00ff00".into())), + ..Default::default() + }, + )], + CssStyle { + color: Some(Color::String("#ff0000".into())), + ..Default::default() + }, + ); + let scene = vec![ChildComponent { + component: card, + position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }]; + let built = build_scene(&scene, (800.0, 600.0)); + let text_box = &built.root.children[0].children[0]; + assert_eq!( + text_box.css.color, + Some(Color::String("#00ff00".into())), + "text child's own explicit color must win over the inherited card color" + ); + } + + #[test] + fn card_display_does_not_cascade_to_text_child() { + // `display` is not an inheritable CSS property — only the documented + // inheritable list (color, font-*, text-align, white-space, ...) + // should propagate. + let card = make_card( + vec![make_text("hello", CssStyle::default())], + CssStyle { + display: Some(Display::Flex), + ..Default::default() + }, + ); + let scene = vec![ChildComponent { + component: card, + position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }]; + let built = build_scene(&scene, (800.0, 600.0)); + let text_box = &built.root.children[0].children[0]; + assert_eq!(text_box.css.display, None); + } + + // ── Round 4 audit, lot LAYOUT, constat 4: `apply_intrinsic_overrides`'s + // default size ignored an explicit `aspect-ratio`. ───────────────────── + + fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent { + ChildComponent { + component: Component::Shape(crate::shape::Shape { + shape: rustmotion_core::schema::ShapeType::Rect, + text: None, + timing: Default::default(), + style: CssStyle { + width: Some(CSize::Length(CLP::Px(width))), + aspect_ratio: Some(aspect_ratio), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + fill: None, + stroke: None, + }), + position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + } + } + + #[test] + fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() { + // `shape`'s hardcoded default is 80×80 (see `apply_intrinsic_overrides`). + // `width: 400` + `aspect-ratio: 16/9` should derive height = 225, not + // fall back to the unrelated 80px default. + let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)]; + let built = build_scene(&scene, (1920.0, 1080.0)); + let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default()); + let l = layout + .get(built.root.children[0].id) + .expect("shape laid out"); + assert!( + (l.width - 400.0).abs() < 1.0, + "width should stay the author's explicit 400, got {}", + l.width + ); + assert!( + (l.height - 225.0).abs() < 1.0, + "height should derive from width/aspect-ratio (400/1.778=225), got {}", + l.height + ); + } + + #[test] + fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() { + // No width/height at all: the natural default width (80 for shape) + // is kept, but height should come from the aspect-ratio, not the + // unrelated 80px default. + let scene = vec![make_aspect_shape_no_width(2.0)]; + let built = build_scene(&scene, (1920.0, 1080.0)); + let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default()); + let l = layout + .get(built.root.children[0].id) + .expect("shape laid out"); + assert!( + (l.width - 80.0).abs() < 1.0, + "width should keep the natural default (80), got {}", + l.width + ); + assert!( + (l.height - 40.0).abs() < 1.0, + "height should derive from the default width/aspect-ratio (80/2=40), got {}", + l.height + ); + } + + fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent { + ChildComponent { + component: Component::Shape(crate::shape::Shape { + shape: rustmotion_core::schema::ShapeType::Rect, + text: None, + timing: Default::default(), + style: CssStyle { + aspect_ratio: Some(aspect_ratio), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + fill: None, + stroke: None, + }), + position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + } + } } diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs index f4da120..056ebe6 100644 --- a/crates/rustmotion-components/src/caption.rs +++ b/crates/rustmotion-components/src/caption.rs @@ -2,7 +2,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, Font, FontStyle, Rect}; -use rustmotion_core::css::style::WhiteSpace as CssWhiteSpace; +use rustmotion_core::css::style::{ + FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, + WhiteSpace as CssWhiteSpace, +}; +use rustmotion_core::css::units::LengthContext; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; @@ -43,12 +47,32 @@ rustmotion_core::impl_traits!(Caption { }); impl Caption { - fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, time: f64) { + fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) { + let time = ctx.time; let font_size = self.style.font_size_px_or(48.0); let color = self.style.color_str_or("#FFFFFF"); let font_family = self.style.font_family_or("Inter"); - let Ok(typeface) = typeface_with_fallback(font_family, FontStyle::bold()) else { + // #9: `letter-spacing`/`line-height` `em`/`%` resolve against this + // element's own font-size (just above); `vw`/`vh` resolve against + // the real viewport, available here via `ctx` (mirrors + // `text.rs::paint`'s `type_ctx`). + let type_ctx = LengthContext { + viewport_width: ctx.video_width as f32, + viewport_height: ctx.video_height as f32, + parent_size: layout_width.max(0.0), + font_size, + root_font_size: 16.0, + }; + + // #9: derive weight/slant from `style.font-weight`/`font-style` + // instead of always painting bold. `CaptionIntrinsic` (via + // `TextIntrinsic`) measures at whatever weight the style declares + // (400/normal when unset) — painting an unconditional bold made the + // glyphs wider than the box that was centred/measured for them. + let font_style = Self::resolve_font_style(&self.style); + + let Ok(typeface) = typeface_with_fallback(font_family, font_style) else { return; }; @@ -182,8 +206,20 @@ impl Caption { self.style.white_space, Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) ); + // #1: when `max_width` is unset, wrap at the box `layout` + // actually gave this caption (matches `text.rs:442-451`) + // instead of never wrapping — `CaptionIntrinsic` measures + // (and taffy reserves a box) against that same width, so + // painting at `f32::MAX` here painted a single line far + // wider than the reserved box, bleeding past it and past + // the viewport with `validate` never seeing the mismatch + // (it re-measures via the same intrinsic, not this paint + // path). let max_width = if nowrap { f32::MAX + } else if layout_width.is_finite() && layout_width > 0.0 { + self.max_width + .map_or(layout_width, |mw| mw.min(layout_width)) } else { self.max_width.unwrap_or(f32::MAX) }; @@ -203,7 +239,16 @@ impl Caption { current_x += word_width + space_width; } - let line_height = font_size * 1.4; + // #9: honour `style.line-height` like `CaptionIntrinsic` + // does (via `TextIntrinsic::from_parts` -> + // `line_height_for_ctx`) instead of a hardcoded 1.4 — the + // box taffy reserves is sized from the former, so painting + // with the latter drifted the line spacing away from what + // was measured (7.7% at the unset default, arbitrarily more + // with an explicit `line-height`), and the caption's own + // vertical clip (below in the outer `paint`) silently crops + // whatever spills past the mismatch. + let line_height = self.style.line_height_for_ctx(font_size, &type_ctx); let cx = layout_width / 2.0; if let Some(bg_color) = self.style.background_color_str() { @@ -291,6 +336,29 @@ impl Caption { let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR)); canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint); } + + /// #9: the Skia `FontStyle` to paint with, derived from `style.font- + /// weight`/`font-style` — mirrors `text.rs`'s weight/slant mapping and + /// `intrinsic.rs`'s `weight_to_u16` (used to measure the box), so the + /// weight the box was measured at and the weight painted into it always + /// agree. Pulled out as its own function so it's directly unit-testable + /// without needing to render anything. + fn resolve_font_style(style: &CssStyle) -> FontStyle { + let weight = match &style.font_weight { + Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => { + skia_safe::font_style::Weight::BOLD + } + Some(CssFontWeight::Number(n)) if *n >= 600 => skia_safe::font_style::Weight::BOLD, + Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32), + _ => skia_safe::font_style::Weight::NORMAL, + }; + let slant = match style.font_style { + Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic, + Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique, + _ => skia_safe::font_style::Slant::Upright, + }; + FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant) + } } impl Painter for Caption { @@ -301,7 +369,7 @@ impl Painter for Caption { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - self.paint(canvas, layout.width, layout.height, ctx.time); + self.paint(canvas, layout.width, layout.height, ctx); } } @@ -333,7 +401,30 @@ mod tests { use rustmotion_core::css::Length; use rustmotion_core::schema::CaptionWord; + /// A `PaintCtx` for tests that don't care about frame/fps bookkeeping — + /// only `time` and, since #9, the viewport dims threaded into the + /// `LengthContext` used to resolve `vw`/`vh` typography units. + fn test_ctx(time: f64) -> PaintCtx { + PaintCtx { + time, + scene_duration: 2.0, + frame_index: (time * 30.0) as u32, + fps: 30, + video_width: 1920, + video_height: 1080, + stagger_offset: 0.0, + } + } + fn make_caption(text: &str, white_space: Option) -> Caption { + make_caption_with_max_width(text, white_space, Some(80.0)) + } + + fn make_caption_with_max_width( + text: &str, + white_space: Option, + max_width: Option, + ) -> Caption { let words = text .split_whitespace() .map(|w| CaptionWord { @@ -346,7 +437,7 @@ mod tests { words, active_color: default_active_color(), mode: CaptionStyle::Highlight, - max_width: Some(80.0), + max_width, pill_color: None, style: CssStyle { font_size: Some(Length::Px(28.0)), @@ -410,7 +501,7 @@ mod tests { let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = surface.canvas(); - caption.paint(canvas, W as f32, H as f32, 0.5); + caption.paint(canvas, W as f32, H as f32, &test_ctx(0.5)); } let (_minx, _maxx, miny, _maxy) = ink_bounds(&mut surface, W, H).expect("caption must paint something"); @@ -431,7 +522,7 @@ mod tests { let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = surface.canvas(); - caption.paint(canvas, W as f32, H as f32, 0.1); + caption.paint(canvas, W as f32, H as f32, &test_ctx(0.1)); } let (_minx, _maxx, miny, _maxy) = ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something"); @@ -453,7 +544,7 @@ mod tests { { let canvas = surface.canvas(); canvas.translate((800.0, 250.0)); - caption.paint(canvas, 80.0, H as f32, 0.5); + caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5)); } let (minx, maxx, miny, maxy) = ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something"); @@ -479,7 +570,7 @@ mod tests { { let canvas = surface.canvas(); canvas.translate((800.0, 250.0)); - caption.paint(canvas, 80.0, H as f32, 0.5); + caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5)); } let (minx, maxx, miny, maxy) = ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something"); @@ -495,4 +586,186 @@ mod tests { maxy - miny ); } + + // ─── #1: wrap at the box's layout_width when max_width is unset ─────── + + #[test] + fn wraps_at_layout_width_when_max_width_is_unset() { + // Reproduction: no `max_width` on the caption (the common case — a + // caption's box comes from wherever it's placed, e.g. a card), but + // the layout pass still hands `paint` a real, finite `layout_width` + // (mirrors `CaptionIntrinsic`, which measures against exactly this + // width). Before the fix, `max_width.unwrap_or(f32::MAX)` ignored + // `layout_width` entirely and painted one line stretching far past + // the box — and past the viewport in the audit's repro. + let caption = make_caption_with_max_width( + "the quick brown fox jumps over the lazy dog again", + None, + None, // no explicit max_width + ); + const W: i32 = 1600; + const H: i32 = 400; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.translate((800.0, 200.0)); + // The box the layout pass assigned: 300px wide, well short of + // this sentence's unwrapped width at font-size 28. + caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5)); + } + let (minx, maxx, miny, maxy) = + ink_bounds(&mut surface, W, H).expect("caption must paint something"); + + assert!( + maxx - minx < 320, + "must wrap within ~layout_width (300px), got ink width {}", + maxx - minx + ); + assert!( + maxy - miny > 50, + "must spread across multiple lines when max_width is unset, got ink height {}", + maxy - miny + ); + } + + #[test] + fn nowrap_still_ignores_layout_width_when_max_width_is_unset() { + // Regression guard: the #1 fix must not touch `white-space: + // nowrap`'s existing "always ignore any width constraint" contract. + let caption = make_caption_with_max_width( + "the quick brown fox jumps over the lazy dog", + Some(CssWhiteSpace::Nowrap), + None, + ); + const W: i32 = 1600; + const H: i32 = 400; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.translate((800.0, 200.0)); + caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5)); + } + let (minx, maxx, miny, maxy) = + ink_bounds(&mut surface, W, H).expect("caption must paint something"); + + assert!( + maxx - minx > 400, + "nowrap must still bleed past layout_width, got ink width {}", + maxx - minx + ); + assert!( + maxy - miny < 50, + "nowrap must stay on one line, got ink height {}", + maxy - miny + ); + } + + // ─── #9: line-height / font-weight measure-vs-paint parity ──────────── + + #[test] + fn honours_style_line_height_instead_of_hardcoded_1_4() { + // Reproduction: `style.line-height: 0.9` must change the vertical + // gap between wrapped lines. Before the fix, the painter always + // used `font_size * 1.4` regardless of `style.line-height`, while + // `CaptionIntrinsic` (the box taffy reserves) honoured it — a + // caption author following rules/typography-readability.md's + // guidance to set `line-height` got a box sized for their value but + // glyphs painted at a fixed 1.4. + let mut tight = make_caption_with_max_width( + "one two three four five six seven eight", + None, + Some(80.0), + ); + tight.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(0.9)); + let mut loose = make_caption_with_max_width( + "one two three four five six seven eight", + None, + Some(80.0), + ); + loose.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(2.0)); + + const W: i32 = 1600; + const H: i32 = 800; + + let mut surf_tight = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surf_tight.canvas(); + canvas.translate((800.0, 50.0)); + tight.paint(canvas, 80.0, H as f32, &test_ctx(0.5)); + } + let (_, _, _, tight_maxy) = + ink_bounds(&mut surf_tight, W, H).expect("tight caption must paint something"); + + let mut surf_loose = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surf_loose.canvas(); + canvas.translate((800.0, 50.0)); + loose.paint(canvas, 80.0, H as f32, &test_ctx(0.5)); + } + let (_, _, _, loose_maxy) = + ink_bounds(&mut surf_loose, W, H).expect("loose caption must paint something"); + + assert!( + loose_maxy > tight_maxy + 50, + "line-height: 2.0 must spread lines much further than 0.9 \ + (tight bottom={tight_maxy}, loose bottom={loose_maxy})" + ); + } + + // `Caption::resolve_font_style` is the exact weight/slant computation + // `paint` uses; testing it directly is deterministic regardless of + // whether the system's resolved "bold" and "normal" typefaces happen to + // have visually/metrically distinct advance widths on this particular + // host (on this machine, Helvetica's bold and normal share identical + // glyph metrics — a pixel-width comparison would pass whether or not + // `paint` used the right weight, which isn't a real check of the fix). + + #[test] + fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() { + // #9 (weight half): `CaptionIntrinsic` (via `TextIntrinsic`'s + // `weight_to_u16`) measures at weight 400 when `style.font-weight` + // is unset. Before the fix, `paint` ignored `style.font-weight` + // entirely and always painted `FontStyle::bold()` (weight 700) — a + // silent measure-vs-paint weight mismatch on every caption that + // doesn't set an explicit font-weight (the common case). + let style = CssStyle::default(); + let resolved = Caption::resolve_font_style(&style); + assert_eq!( + *resolved.weight(), + 400, + "unset font-weight must resolve to normal (400), not a hardcoded bold" + ); + } + + #[test] + fn resolve_font_style_honours_explicit_bold_and_numeric_weight() { + let bold = CssStyle { + font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bold)), + ..Default::default() + }; + assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700); + + // Below the >=600 "treat as bold" threshold (same threshold + // `text.rs`'s equivalent mapping uses), so the exact numeric value + // passes through unchanged. + let numeric = CssStyle { + font_weight: Some(CssFontWeight::Number(350)), + ..Default::default() + }; + assert_eq!(*Caption::resolve_font_style(&numeric).weight(), 350); + } + + #[test] + fn resolve_font_style_honours_italic() { + let italic = CssStyle { + font_style: Some(CssFontStyle::Italic), + ..Default::default() + }; + assert_eq!( + Caption::resolve_font_style(&italic).slant(), + skia_safe::font_style::Slant::Italic + ); + } } diff --git a/crates/rustmotion-components/src/chart/bar.rs b/crates/rustmotion-components/src/chart/bar.rs index f492341..05c59f9 100644 --- a/crates/rustmotion-components/src/chart/bar.rs +++ b/crates/rustmotion-components/src/chart/bar.rs @@ -23,7 +23,16 @@ impl Chart { // reduces to the previous `value / max_val`. let (min_val, max_val, range) = self.value_extent(); - let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); + // One slot per data point, unlabeled points included as `""`. + // `draw_axes` positions label `i` at slot `i` of `n = x_labels.len()` + // — a `filter_map` that dropped unlabeled points compacted the list, + // so `n` no longer matched `self.data.len()` and every surviving + // label slid onto the wrong bar as soon as one point had no label. + let x_labels: Vec = self + .data + .iter() + .map(|d| d.label.clone().unwrap_or_default()) + .collect(); self.draw_axes( canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true, @@ -269,33 +278,73 @@ impl Chart { let chart_h = h - mt - mb; let n_cats = self.categories.len(); - // Find max stacked total - let max_val = (0..n_cats) + // A stacked total can go negative — either every series is negative + // (e.g. an all-cost breakdown) or the series mix signs within one + // category (revenue vs. cost). Scale from the true signed extent — + // positive segments stack above zero, negative segments stack below + // — the same zero-anchored contract `value_extent` gives + // `render_bar`. Scaling by the largest *positive* total alone + // (previously `max_val = ... .max(0.001)`, floored to 0.001 when + // every total was negative) sent every segment's height through a + // near-zero divisor and painted it thousands of chart-heights + // outside the box; even a bounded mixed-sign case (revenue stacked + // as if `max_val` were the net total) pushed the positive segment + // taller than the whole chart. + let totals: Vec<(f64, f64)> = (0..n_cats) .map(|ci| { self.series .iter() - .map(|s| s.data.get(ci).copied().unwrap_or(0.0)) - .sum::() + .fold((0.0_f64, 0.0_f64), |(pos, neg), s| { + let v = s.data.get(ci).copied().unwrap_or(0.0); + if v >= 0.0 { + (pos + v, neg) + } else { + (pos, neg + v) + } + }) }) - .fold(0.0_f64, f64::max) - .max(0.001); + .collect(); + let min_val = totals.iter().map(|(_, neg)| *neg).fold(0.0_f64, f64::min); + let max_val = totals.iter().map(|(pos, _)| *pos).fold(0.0_f64, f64::max); + let range = (max_val - min_val).max(0.001); let x_labels: Vec = self.categories.clone(); self.draw_axes( - canvas, ml, mt, chart_w, chart_h, 0.0, max_val, &x_labels, true, + canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true, ); let gap = 8.0; let bar_w = (chart_w - gap * (n_cats + 1) as f32) / n_cats as f32; + let zero_y = mt + chart_h - ((0.0 - min_val) / range) as f32 * chart_h; for ci in 0..n_cats { let x = ml + gap + ci as f32 * (bar_w + gap); - let mut cumulative_h = 0.0_f32; + // Positive segments stack upward from `zero_y`; negative + // segments stack downward from it, independently — each + // direction has its own running edge. + let mut pos_top = zero_y; + let mut neg_bottom = zero_y; + let last_pos_si = + self.series.iter().enumerate().rev().find_map(|(si, s)| { + (s.data.get(ci).copied().unwrap_or(0.0) > 0.0).then_some(si) + }); + let last_neg_si = + self.series.iter().enumerate().rev().find_map(|(si, s)| { + (s.data.get(ci).copied().unwrap_or(0.0) < 0.0).then_some(si) + }); for (si, series) in self.series.iter().enumerate() { let val = series.data.get(ci).copied().unwrap_or(0.0); - let seg_h = (val / max_val) as f32 * chart_h * progress; - let y = mt + chart_h - cumulative_h - seg_h; + if val == 0.0 { + continue; + } + let seg_h = (val.abs() / range) as f32 * chart_h * progress; + let negative = val < 0.0; + let y = if negative { + neg_bottom + } else { + pos_top - seg_h + }; let color = series .color @@ -306,27 +355,328 @@ impl Chart { paint.set_anti_alias(true); let rect = Rect::from_xywh(x, y, bar_w, seg_h); - // Rounded top on the topmost segment only - if si == self.series.len() - 1 { + // Round the outer edge of each stack: the top of the + // topmost positive segment, the bottom of the bottommost + // negative segment. + let is_outer_edge = if negative { + Some(si) == last_neg_si + } else { + Some(si) == last_pos_si + }; + if is_outer_edge { let radius = (bar_w * 0.15).min(8.0); - let rrect = skia_safe::RRect::new_rect_radii( - rect, - &[ - (radius, radius).into(), - (radius, radius).into(), - (0.0, 0.0).into(), - (0.0, 0.0).into(), - ], - ); + let round = (radius, radius).into(); + let square = (0.0, 0.0).into(); + let radii = if negative { + [square, square, round, round] + } else { + [round, round, square, square] + }; + let rrect = skia_safe::RRect::new_rect_radii(rect, &radii); canvas.draw_rrect(rrect, &paint); } else { canvas.draw_rect(rect, &paint); } - cumulative_h += seg_h; + if negative { + neg_bottom += seg_h; + } else { + pos_top -= seg_h; + } } } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::chart::{ChartDataPoint, ChartSeries, ChartType}; + use rustmotion_core::css::CssStyle; + use rustmotion_core::traits::TimingConfig; + + fn base_chart(chart_type: ChartType) -> Chart { + Chart { + chart_type, + data: Vec::new(), + animated: true, + animation_duration: 1.5, + colors: None, + inner_radius: 0.6, + fill_opacity: 0.3, + smooth: false, + categories: Vec::new(), + series: Vec::new(), + axes: Vec::new(), + radar_data: Vec::new(), + points: Vec::new(), + direction: None, + show_grid: false, + show_x_labels: false, + show_y_labels: false, + grid_color: "#FFFFFF15".to_string(), + label_color: "#888888".to_string(), + label_font_size: 18.0, + show_labels: false, + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent + /// pixel on the surface, or `None` if nothing was painted. Mirrors the + /// helper `stat.rs`/`caption.rs` already use for the same kind of proof. + fn ink_bounds( + surface: &mut skia_safe::Surface, + w: i32, + h: i32, + ) -> Option<(i32, i32, i32, i32)> { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + minx = minx.min(x); + maxx = maxx.max(x); + miny = miny.min(y); + maxy = maxy.max(y); + } + } + } + (minx <= maxx).then_some((minx, maxx, miny, maxy)) + } + + /// Column-wise ink centers: for each x with any ink, returns the + /// vertical midpoint of the ink found in that column. Used to find the + /// horizontal center of a run of colored pixels (a bar or a label). + fn colored_columns( + surface: &mut skia_safe::Surface, + w: i32, + h: i32, + matches_color: impl Fn(u8, u8, u8) -> bool, + ) -> Vec { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + let mut cols = vec![]; + for x in 0..w { + let mut any = false; + for y in 0..h { + let idx = ((y * w + x) * 4) as usize; + let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]); + if a > 0 && matches_color(r, g, b) { + any = true; + break; + } + } + if any { + cols.push(x); + } + } + cols + } + + /// Paint a stacked-bar chart for a `box_w`x`box_h` box, but into a + /// canvas with `MARGIN` px of headroom above and below it, and return + /// the ink's vertical extent in *box-local* coordinates (0 = box top). + /// + /// A same-size canvas hides the bug this exists to catch: an + /// overflowing segment's `Rect::from_xywh` gets a huge *negative* + /// height, which skia normalizes when drawing, so a coincidental sliver + /// of the inverted rect can still land inside a same-size canvas even + /// though the segment as a whole is nowhere near the box. Margin on + /// both sides catches overflow in either direction; local coordinates + /// let the assertion read directly as "outside the box" without redoing + /// the offset math at every call site. + fn stacked_bar_ink_local_range(chart: &Chart, box_w: i32, box_h: i32, time: f64) -> (i32, i32) { + const MARGIN: i32 = 300; + let surface_h = box_h + MARGIN * 2; + let mut surface = + skia_safe::surfaces::raster_n32_premul((box_w, surface_h)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.translate((0.0, MARGIN as f32)); + chart + .paint(canvas, box_w as f32, box_h as f32, time) + .expect("paint must not error"); + } + let (_minx, _maxx, miny, maxy) = ink_bounds(&mut surface, box_w, surface_h) + .expect("stacked bar chart must paint visible bars"); + (miny - MARGIN, maxy - MARGIN) + } + + #[test] + fn stacked_bar_with_all_negative_totals_stays_inside_the_box() { + // #2's exact repro: every category totals negative, so the old + // `max_val = ... .max(0.001)` floor made `seg_h` divide by a + // near-zero value and blew the segment thousands of chart-heights + // past the bottom of the box. + let mut chart = base_chart(ChartType::StackedBar); + chart.categories = vec!["Q1".to_string(), "Q2".to_string()]; + chart.series = vec![ChartSeries { + name: "net".to_string(), + data: vec![-5.0, -3.0], + color: None, + }]; + + let (local_min, local_max) = stacked_bar_ink_local_range(&chart, 400, 300, 10.0); + assert!( + local_min >= 0 && local_max < 300, + "ink escaped the 400x300 box vertically: local y=[{local_min}..{local_max}]" + ); + } + + #[test] + fn stacked_bar_with_mixed_sign_series_stays_inside_the_box() { + // Revenue/cost style stack: mixed signs within the same category. + // `max_val` alone (the pre-fix scale) ignored the negative side + // entirely, so the cost segment was scaled as if it were tiny and + // painted far below the box. + let mut chart = base_chart(ChartType::StackedBar); + chart.categories = vec!["Q1".to_string(), "Q2".to_string()]; + chart.series = vec![ + ChartSeries { + name: "revenue".to_string(), + data: vec![10.0, 8.0], + color: None, + }, + ChartSeries { + name: "cost".to_string(), + data: vec![-5.0, -3.0], + color: None, + }, + ]; + + let (local_min, local_max) = stacked_bar_ink_local_range(&chart, 400, 300, 10.0); + assert!( + local_min >= 0 && local_max < 300, + "ink escaped the 400x300 box vertically: local y=[{local_min}..{local_max}]" + ); + } + + #[test] + fn bar_x_labels_align_with_their_own_bar_when_some_points_are_unlabeled() { + // #5's exact repro: with `filter_map` compacting the label list, the + // 2 surviving labels ("AAA", "DDD") were spread across only 2 of the + // 4 slots `draw_axes` computes from `x_labels.len()`, sliding every + // label onto the wrong bar. + let mut chart = base_chart(ChartType::Bar); + chart.show_x_labels = true; + chart.data = vec![ + ChartDataPoint { + value: 50.0, + label: Some("AAA".to_string()), + color: Some("#0000FF".to_string()), + }, + ChartDataPoint { + value: 50.0, + label: None, + color: Some("#0000FF".to_string()), + }, + ChartDataPoint { + value: 50.0, + label: None, + color: Some("#0000FF".to_string()), + }, + ChartDataPoint { + value: 50.0, + label: Some("DDD".to_string()), + color: Some("#0000FF".to_string()), + }, + ]; + + const W: i32 = 400; + const H: i32 = 300; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let bar_cols = { + let canvas = surface.canvas(); + chart + .paint(canvas, W as f32, H as f32, 10.0) + .expect("paint must not error"); + colored_columns(&mut surface, W, H, |r, g, b| r < 40 && g < 40 && b > 200) + }; + // The bar (blue) columns split into 4 contiguous runs (gaps between + // them). The label (red) columns should center within a small + // distance of the *first* and *last* runs' centers, not drift onto + // neighboring slots. + assert!(!bar_cols.is_empty(), "no bars painted"); + let mut runs: Vec<(i32, i32)> = vec![]; + for x in bar_cols { + match runs.last_mut() { + Some((_, end)) if x <= *end + 1 => *end = x, + _ => runs.push((x, x)), + } + } + assert_eq!(runs.len(), 4, "expected 4 bar slots, got {runs:?}"); + let bar0_center = (runs[0].0 + runs[0].1) as f32 / 2.0; + let bar3_center = (runs[3].0 + runs[3].1) as f32 / 2.0; + + let mut label_surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + let label_cols = { + let canvas = label_surface.canvas(); + chart + .paint(canvas, W as f32, H as f32, 10.0) + .expect("paint must not error"); + colored_columns(&mut label_surface, W, H, |r, g, b| { + // label_color default #888888 + (120..160).contains(&r) && (120..160).contains(&g) && (120..160).contains(&b) + }) + }; + assert!(!label_cols.is_empty(), "no labels painted"); + let mut label_runs: Vec<(i32, i32)> = vec![]; + for x in label_cols { + match label_runs.last_mut() { + Some((_, end)) if x <= *end + 4 => *end = x, + _ => label_runs.push((x, x)), + } + } + assert_eq!( + label_runs.len(), + 2, + "expected 2 label runs (AAA, DDD), got {label_runs:?}" + ); + let label_aaa_center = (label_runs[0].0 + label_runs[0].1) as f32 / 2.0; + let label_ddd_center = (label_runs[1].0 + label_runs[1].1) as f32 / 2.0; + + assert!( + (label_aaa_center - bar0_center).abs() < 15.0, + "AAA label (center {label_aaa_center}) should sit under bar 0 (center {bar0_center})" + ); + assert!( + (label_ddd_center - bar3_center).abs() < 15.0, + "DDD label (center {label_ddd_center}) should sit under bar 3 (center {bar3_center})" + ); + } +} diff --git a/crates/rustmotion-components/src/chart/line.rs b/crates/rustmotion-components/src/chart/line.rs index 3576fc0..16951d6 100644 --- a/crates/rustmotion-components/src/chart/line.rs +++ b/crates/rustmotion-components/src/chart/line.rs @@ -43,7 +43,11 @@ impl Chart { let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value)); let n = self.data.len(); - let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); + let x_labels: Vec = self + .data + .iter() + .map(|d| d.label.clone().unwrap_or_default()) + .collect(); self.draw_axes( canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false, ); @@ -131,7 +135,11 @@ impl Chart { let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value)); let n = self.data.len(); - let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); + let x_labels: Vec = self + .data + .iter() + .map(|d| d.label.clone().unwrap_or_default()) + .collect(); self.draw_axes( canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false, ); diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs index 3409e12..72e1b5c 100644 --- a/crates/rustmotion-components/src/chart/mod.rs +++ b/crates/rustmotion-components/src/chart/mod.rs @@ -210,7 +210,13 @@ impl Chart { if !self.animated { return 1.0; } - let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32; + // Ramp measured from `start_at`, not from scene time zero — matches + // `Counter::ramp_progress`. A chart delayed with `start_at` used to + // read raw scene time, so it was already fully drawn on the very + // first frame it became visible. + let start = self.timing.start_at.unwrap_or(0.0); + let elapsed = (time - start).max(0.0); + let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; // ease_out_cubic 1.0 - (1.0 - p).powi(3) } @@ -318,3 +324,87 @@ impl Painter for Chart { let _ = self.paint(canvas, layout.width, layout.height, ctx.time); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::traits::TimingConfig; + + fn base_chart() -> Chart { + Chart { + chart_type: ChartType::Bar, + data: Vec::new(), + animated: true, + animation_duration: 1.5, + colors: None, + inner_radius: 0.6, + fill_opacity: 0.3, + smooth: false, + categories: Vec::new(), + series: Vec::new(), + axes: Vec::new(), + radar_data: Vec::new(), + points: Vec::new(), + direction: None, + show_grid: false, + show_x_labels: false, + show_y_labels: false, + grid_color: default_grid_color(), + label_color: default_label_color(), + label_font_size: default_label_font_size(), + show_labels: false, + timing: TimingConfig::default(), + style: rustmotion_core::css::CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + #[test] + fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() { + // #3's exact repro: a chart delayed with `start_at: 2.0` and + // `animation_duration: 1.5` was already fully drawn (progress 1.0) + // on the very first frame it became visible, because the ramp read + // raw scene time instead of time-since-`start_at` — the same defect + // `Counter::ramp_progress` was fixed for. + let mut chart = base_chart(); + chart.animation_duration = 1.5; + chart.timing = TimingConfig { + start_at: Some(2.0), + end_at: None, + }; + + assert_eq!( + chart.progress_at(2.0), + 0.0, + "no time has elapsed since start_at yet" + ); + assert!( + chart.progress_at(2.75) < 1.0, + "still mid-ramp half a second after start_at" + ); + assert_eq!( + chart.progress_at(3.5), + 1.0, + "animation_duration has fully elapsed since start_at" + ); + } + + #[test] + fn progress_ramp_with_no_start_at_behaves_like_before() { + let chart = base_chart(); + assert_eq!(chart.progress_at(0.0), 0.0); + assert_eq!(chart.progress_at(1.5), 1.0); + } + + #[test] + fn progress_ramp_when_not_animated_is_always_complete() { + let mut chart = base_chart(); + chart.animated = false; + chart.timing = TimingConfig { + start_at: Some(2.0), + end_at: None, + }; + assert_eq!(chart.progress_at(0.0), 1.0); + } +} diff --git a/crates/rustmotion-components/src/chart/waterfall.rs b/crates/rustmotion-components/src/chart/waterfall.rs index 8b7c269..bda5090 100644 --- a/crates/rustmotion-components/src/chart/waterfall.rs +++ b/crates/rustmotion-components/src/chart/waterfall.rs @@ -35,7 +35,11 @@ impl Chart { let max_val = all_vals.iter().fold(f64::MIN, |a, &b| a.max(b)); let range = (max_val - min_val).max(0.001); - let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); + let x_labels: Vec = self + .data + .iter() + .map(|d| d.label.clone().unwrap_or_default()) + .collect(); self.draw_axes( canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true, ); diff --git a/crates/rustmotion-components/src/codeblock/diff.rs b/crates/rustmotion-components/src/codeblock/diff.rs index 5726c80..864d614 100644 --- a/crates/rustmotion-components/src/codeblock/diff.rs +++ b/crates/rustmotion-components/src/codeblock/diff.rs @@ -444,7 +444,7 @@ pub(super) fn compute_word_diff(old_line: &str, new_line: &str) -> Vec { pending_delete.push_str(change.value()); @@ -456,7 +456,7 @@ pub(super) fn compute_word_diff(old_line: &str, new_line: &str) -> Vec Vec usize { + s.char_indices() + .nth(char_idx) + .map(|(i, _)| i) + .unwrap_or(s.len()) +} + pub(super) fn draw_cursor_edited_line( canvas: &Canvas, font: &Font, @@ -498,7 +511,10 @@ pub(super) fn draw_cursor_edited_line( return; } - let total_work: usize = edits.iter().map(|e| e.delete.len() + e.insert.len()).sum(); + let total_work: usize = edits + .iter() + .map(|e| e.delete.chars().count() + e.insert.chars().count()) + .sum(); if total_work == 0 { let highlighted = highlight_code(new_line, language, theme); if let Some(line) = highlighted.first() { @@ -515,32 +531,33 @@ pub(super) fn draw_cursor_edited_line( for edit in edits { let adjusted_col = (edit.col as i64 + offset_adjust).max(0) as usize; - let delete_len = edit.delete.len(); - let insert_len = edit.insert.len(); + let delete_len = edit.delete.chars().count(); + let insert_len = edit.insert.chars().count(); let edit_work = delete_len + insert_len; if work_done + edit_work <= chars_progress { - let end = (adjusted_col + delete_len).min(current_line.len()); - let start = adjusted_col.min(current_line.len()); - current_line.replace_range(start..end, &edit.insert); + let start = byte_at(¤t_line, adjusted_col); + let end = byte_at(¤t_line, adjusted_col + delete_len); + current_line.replace_range(start..end.max(start), &edit.insert); offset_adjust += insert_len as i64 - delete_len as i64; work_done += edit_work; } else { let remaining_progress = chars_progress - work_done; if remaining_progress < delete_len { let chars_deleted = remaining_progress; - let del_start = (adjusted_col + delete_len - chars_deleted).min(current_line.len()); - let del_end = (adjusted_col + delete_len).min(current_line.len()); + let first_deleted = adjusted_col + delete_len - chars_deleted; + let del_start = byte_at(¤t_line, first_deleted); + let del_end = byte_at(¤t_line, adjusted_col + delete_len); if del_start < del_end { current_line.replace_range(del_start..del_end, ""); } - cursor_col = Some(del_start.min(current_line.len())); + cursor_col = Some(first_deleted); } else { let chars_inserted = remaining_progress - delete_len; - let start = adjusted_col.min(current_line.len()); - let end = (adjusted_col + delete_len).min(current_line.len()); - let partial_insert = &edit.insert[..chars_inserted.min(edit.insert.len())]; - current_line.replace_range(start..end, partial_insert); + let start = byte_at(¤t_line, adjusted_col); + let end = byte_at(¤t_line, adjusted_col + delete_len); + let partial_insert = &edit.insert[..byte_at(&edit.insert, chars_inserted)]; + current_line.replace_range(start..end.max(start), partial_insert); cursor_col = Some(adjusted_col + chars_inserted); } break; @@ -562,7 +579,8 @@ pub(super) fn draw_cursor_edited_line( }; if should_show { - let prefix = ¤t_line[..col.min(current_line.len())]; + // `col` counts characters, like every other column here. + let prefix = ¤t_line[..byte_at(¤t_line, col)]; let (prefix_width, _) = font.measure_str(prefix, None); let cursor_x = x + prefix_width; let mut cursor_paint = paint_from_hex(cursor_color); diff --git a/crates/rustmotion-components/src/codeblock/highlight.rs b/crates/rustmotion-components/src/codeblock/highlight.rs index 8dfc5cf..dc9c6b5 100644 --- a/crates/rustmotion-components/src/codeblock/highlight.rs +++ b/crates/rustmotion-components/src/codeblock/highlight.rs @@ -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", @@ -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); + } +} diff --git a/crates/rustmotion-components/src/codeblock/render.rs b/crates/rustmotion-components/src/codeblock/render.rs index 6c770ba..1c98c46 100644 --- a/crates/rustmotion-components/src/codeblock/render.rs +++ b/crates/rustmotion-components/src/codeblock/render.rs @@ -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"); @@ -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(¤t_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 }; @@ -149,9 +181,8 @@ pub(super) fn render_codeblock( trans, ); } else { - let highlighted = highlight_code(¤t_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( diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index 4bc85dd..d9511e4 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -295,7 +295,10 @@ mod tests { suffix: None, easing: EasingType::default(), duration, - timing: TimingConfig { start_at, end_at: None }, + timing: TimingConfig { + start_at, + end_at: None, + }, style: CssStyle::default(), timeline: Vec::new(), stagger: None, diff --git a/crates/rustmotion-components/src/dot_map.rs b/crates/rustmotion-components/src/dot_map.rs index ae69d1f..5e5680a 100644 --- a/crates/rustmotion-components/src/dot_map.rs +++ b/crates/rustmotion-components/src/dot_map.rs @@ -133,7 +133,11 @@ impl DotMap { if !self.animated { return 1.0; } - let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32; + // Measure from `start_at`, like every other animated component: driving + // the ramp off raw scene time makes a delayed map arrive already drawn. + let start = self.timing.start_at.unwrap_or(0.0); + let elapsed = (time - start).max(0.0); + let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; 1.0 - (1.0 - p).powi(3) } @@ -154,12 +158,23 @@ impl DotMap { world_paint.set_style(PaintStyle::Fill); world_paint.set_anti_alias(true); - let spacing = self.dot_spacing; + // A spacing at or below zero makes the division +inf, and `inf as u32` + // saturates to u32::MAX in Rust — the nested loop below would then be + // scheduled for ~1.8e19 iterations and never return. The geometry pass + // rejects 0.01 but not 0, so the floor has to live here. + let spacing = if self.dot_spacing.is_finite() && self.dot_spacing >= 1.0 { + self.dot_spacing + } else { + 1.0 + }; let radius = self.dot_radius; let margin = spacing; - let cols = ((w - margin * 2.0) / spacing) as u32; - let rows = ((h - margin * 2.0) / spacing) as u32; + // Belt and braces: even a legal spacing on a very large box should not + // be able to schedule an unbounded amount of work. + const MAX_DOTS_PER_AXIS: u32 = 4096; + let cols = (((w - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS); + let rows = (((h - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS); for row in 0..rows { for col in 0..cols { diff --git a/crates/rustmotion-components/src/gauge.rs b/crates/rustmotion-components/src/gauge.rs index c62449d..bb939c9 100644 --- a/crates/rustmotion-components/src/gauge.rs +++ b/crates/rustmotion-components/src/gauge.rs @@ -95,7 +95,11 @@ impl Gauge { if !self.animated { return 1.0; } - let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32; + // Measure from `start_at`, like every other animated component: driving + // the ramp off raw scene time makes a delayed gauge arrive already full. + let start = self.timing.start_at.unwrap_or(0.0); + let elapsed = (time - start).max(0.0); + let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; 1.0 - (1.0 - p).powi(3) } diff --git a/crates/rustmotion-components/src/heatmap.rs b/crates/rustmotion-components/src/heatmap.rs index 246ae1f..bfb20e4 100644 --- a/crates/rustmotion-components/src/heatmap.rs +++ b/crates/rustmotion-components/src/heatmap.rs @@ -90,11 +90,16 @@ fn interpolate_color(scale: &[String], t: f32) -> (u8, u8, u8) { return (r, g, b); } let n = scale.len() - 1; - let segment = (t * n as f32).floor() as usize; - let local_t = t * n as f32 - segment as f32; - let i = segment.min(n - 1); - let (r1, g1, b1, _) = parse_hex_color(&scale[i]); - let (r2, g2, b2, _) = parse_hex_color(&scale[i + 1]); + let scaled = t * n as f32; + // Clamp the segment index (t=1.0 lands exactly on `n`, one past the + // last valid segment), but re-derive `local_t` from the *clamped* + // segment rather than reusing the unclamped one — otherwise t=1.0 + // computed local_t=0.0 against the clamped (second-to-last) segment and + // resolved to the second-to-last color instead of the last one. + let segment = (scaled.floor() as usize).min(n - 1); + let local_t = (scaled - segment as f32).clamp(0.0, 1.0); + let (r1, g1, b1, _) = parse_hex_color(&scale[segment]); + let (r2, g2, b2, _) = parse_hex_color(&scale[segment + 1]); ( lerp_u8(r1, r2, local_t), lerp_u8(g1, g2, local_t), @@ -107,7 +112,13 @@ impl Heatmap { if !self.animated { return 1.0; } - let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32; + // Ramp measured from `start_at`, not from scene time zero — matches + // `Counter::ramp_progress`. A heatmap delayed with `start_at` used + // to read raw scene time, so it was already fully revealed on the + // very first frame it became visible. + let start = self.timing.start_at.unwrap_or(0.0); + let elapsed = (time - start).max(0.0); + let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; 1.0 - (1.0 - p).powi(3) } @@ -121,17 +132,6 @@ impl Heatmap { let progress = self.progress_at(time); - // Find min/max across all cells - let mut min_val = f64::MAX; - let mut max_val = f64::MIN; - for row in &self.data { - for &val in row { - min_val = min_val.min(val); - max_val = max_val.max(val); - } - } - let range = (max_val - min_val).max(0.001); - // Animation: clip rect expanding from left to right let clip_w = w * progress; canvas.save(); @@ -145,7 +145,15 @@ impl Heatmap { for (row_idx, row) in self.data.iter().enumerate() { for (col_idx, &val) in row.iter().enumerate() { - let normalized = ((val - min_val) / range) as f32; + // `color_scale` documents an absolute 0.0-1.0 semantic + // (SKILL.md: "2D array of f64, values 0.0-1.0"), not a + // per-render min-max scale. Renormalizing meant a grid of + // constant values (or any subrange, e.g. [0.8, 0.9, 1.0]) + // painted identically to a grid of zeros — a flat or + // uniformly-high grid is not the same fact as "nothing + // happened". Clamp into the documented range instead of + // rescaling to whatever the data happens to span. + let normalized = (val as f32).clamp(0.0, 1.0); let (r, g, b) = interpolate_color(&self.color_scale, normalized); let x = col_idx as f32 * step; @@ -179,3 +187,114 @@ impl Painter for Heatmap { self.paint(canvas, layout.width, layout.height, ctx.time); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::traits::TimingConfig; + + fn base_heatmap(data: Vec>) -> Heatmap { + Heatmap { + data, + color_scale: default_color_scale(), + cell_size: default_cell_size(), + cell_gap: default_cell_gap(), + cell_radius: default_cell_radius(), + animated: true, + animation_duration: 1.5, + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn cell_color(heatmap: &Heatmap, w: i32, h: i32, time: f64) -> (u8, u8, u8) { + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + { + let canvas = surface.canvas(); + heatmap.paint(canvas, w as f32, h as f32, time); + } + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (1, 1), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = [0u8; 4]; + // Sample the middle of the top-left cell. + let x = (heatmap.cell_size / 2.0) as i32; + let y = (heatmap.cell_size / 2.0) as i32; + snapshot.read_pixels( + &info, + &mut buf, + 4, + skia_safe::IPoint::new(x, y), + skia_safe::image::CachingHint::Disallow, + ); + (buf[0], buf[1], buf[2]) + } + + #[test] + fn a_uniformly_low_grid_is_not_identical_to_an_all_zero_grid() { + // #6's exact repro: `color_scale` is documented (SKILL.md) as an + // *absolute* 0.0-1.0 scale, but the painter renormalized min→max — + // so a grid of constant 5.0s (or any other constant) rendered + // pixel-for-pixel identical to a grid of constant 0.0s, both + // collapsing to the scale's first (lowest) color. + let uniform = base_heatmap(vec![vec![5.0, 5.0, 5.0], vec![5.0, 5.0, 5.0]]); + let zero = base_heatmap(vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]]); + let uniform_color = cell_color(&uniform, 200, 100, 10.0); + let zero_color = cell_color(&zero, 200, 100, 10.0); + assert_ne!( + uniform_color, zero_color, + "a grid of 5.0s must not render identically to a grid of 0.0s" + ); + } + + #[test] + fn absolute_values_are_not_renormalized_to_the_data_subrange() { + // A grid whose values happen to span [0.8, 1.0] must not stretch + // that subrange to fill the whole color scale — 0.8 reads as + // "mostly full", not as "the bottom of whatever this grid contains". + let high = base_heatmap(vec![vec![0.8, 0.9, 1.0]]); + let low = base_heatmap(vec![vec![0.0, 0.1, 0.2]]); + let high_first_cell = cell_color(&high, 200, 100, 10.0); + let low_first_cell = cell_color(&low, 200, 100, 10.0); + assert_ne!( + high_first_cell, low_first_cell, + "0.8 and 0.0 must not render as the same color" + ); + } + + #[test] + fn interpolate_color_at_the_top_of_the_scale_returns_the_last_color() { + // Surfaced while chasing #6: the clamped segment index was reused + // for `local_t` too, so t=1.0 exactly computed `local_t = 0.0` for + // the *clamped* (second-to-last) segment instead of `local_t = 1.0` + // — landing on the second-to-last color rather than the last + // (brightest) one. + let scale = default_color_scale(); + let (r, g, b) = interpolate_color(&scale, 1.0); + let (er, eg, eb, _) = parse_hex_color(scale.last().unwrap()); + assert_eq!( + (r, g, b), + (er, eg, eb), + "t=1.0 must resolve to the last color in the scale" + ); + } + + #[test] + fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() { + let mut heatmap = base_heatmap(vec![vec![1.0]]); + heatmap.animation_duration = 1.5; + heatmap.timing = TimingConfig { + start_at: Some(2.0), + end_at: None, + }; + assert_eq!(heatmap.progress_at(2.0), 0.0); + assert!(heatmap.progress_at(2.75) < 1.0); + assert_eq!(heatmap.progress_at(3.5), 1.0); + } +} diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 9e67e9b..5eb0215 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -24,7 +24,12 @@ use crate::gradient_text::GradientText; use crate::kbd::Kbd; use crate::text::Text; -/// Cosmic-text–backed intrinsic measurer for [`Text`]. +/// Skia-backed intrinsic measurer for [`Text`] (audit #10: despite the name +/// this module's doc header suggests, this uses `skia_safe::Font:: +/// measure_str` via `engine::renderer::text`'s fallback-aware helpers — the +/// same primitives `Text::paint` draws with — not `engine::text::cosmic`, +/// which has no callers on the real render path at all; see that module's +/// doc comment). pub struct TextIntrinsic { content: String, font_family: Option, @@ -58,20 +63,38 @@ impl TextIntrinsic { /// wrap:true unconditionally so their measured size still matches what /// those painters actually draw. pub fn from_parts(content: &str, style: &CssStyle, max_width: Option) -> Self { - // No `LengthContext` is reachable here without changing this - // constructor's signature — its only callers are `box_builder.rs` - // and `rustmotion-cli/src/commands/geometry.rs`, both outside this + // No *real* `LengthContext` (real viewport, real parent width) is + // reachable here without changing this constructor's signature — + // its only callers are `box_builder.rs` and + // `rustmotion-cli/src/commands/geometry.rs`, both outside this // workstream's scope (box_builder.rs is a sibling's live file this // wave; the geometry validator re-measures via this exact type and // must keep agreeing with it byte-for-byte, so changing what it - // needs to pass in is not a call to make unilaterally here). So - // `font_size`/`line_height` stay on the context-free accessors - // (issue #125 §2's `vw`/`vh`/`rem`/`%` gap is not closed for this - // constructor) — only `letter_spacing` below, which is used - // exclusively by the wrap fix in `measure()`, no signature change - // needed for it. + // needs to pass in is not a call to make unilaterally here). + // + // But `letter-spacing`'s and `line-height`'s `em`/`%` resolve + // against this element's *own* font-size (not the parent's, not the + // viewport) — CSS spec, also documented on + // `CssStyle::letter_spacing_px_ctx`/`line_height_for_ctx` — and that + // own font-size is already known right here, with zero signature + // change needed. Building a `LengthContext` carrying just that + // resolved `font_size` (defaults for everything else) and using the + // `_ctx` resolvers closes the measure-vs-paint divergence for `em`/ + // `%` specifically (`Text`/`Caption`'s painters already resolve + // these two properties with the real `PaintCtx`'s viewport, but + // `em`/`%` on them don't read the viewport at all, so the two agree + // regardless of what viewport this default carries). `vw`/`vh`/ + // `rem` on `letter-spacing`/`line-height` remain unresolved against + // the *real* viewport here (they fall back to this struct's default + // 1920×1080/16px root) — closing that fully needs the real + // `VideoConfig` plumbed through `box_builder.rs`/`geometry.rs`, + // still out of scope for the reasons above. let font_size = style.font_size_px_or(48.0); - let line_height_resolved = style.line_height_for(font_size); + let own_ctx = rustmotion_core::css::units::LengthContext { + font_size, + ..rustmotion_core::css::units::LengthContext::default() + }; + let line_height_resolved = style.line_height_for_ctx(font_size, &own_ctx); Self { content: content.to_string(), font_family: style.font_family.clone(), @@ -79,7 +102,7 @@ impl TextIntrinsic { line_height_resolved, weight: weight_to_u16(style.font_weight.as_ref()), italic: matches!(style.font_style, Some(CssFontStyle::Italic)), - letter_spacing: style.letter_spacing_px(), + letter_spacing: style.letter_spacing_px_ctx(&own_ctx), max_width, wrap: true, } @@ -404,8 +427,8 @@ fn _line_height_unused(_: Option<&LineHeight>) {} // ───────────────────────────────────────────────────────────────────────────── use crate::terminal::{ - Terminal, CHROME_HEIGHT, FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT, - PADDING as TERM_PADDING, + resolve_typeface as resolve_terminal_typeface, Terminal, CHROME_HEIGHT, + FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT, PADDING as TERM_PADDING, }; /// Intrinsic measurer for [`Terminal`]. @@ -445,8 +468,10 @@ impl TerminalIntrinsic { } fn measure_max_width(t: &Terminal, font_size: f32) -> f32 { - let font_style = skia_safe::FontStyle::normal(); - let Ok(typeface) = typeface_with_fallback("SF Mono", font_style) else { + // Same resolver the painter calls — see `terminal::resolve_typeface`. + // Measuring with one face and painting with another is how text ends up + // overflowing a box the geometry pass has already approved. + let Some(typeface) = resolve_terminal_typeface(&t.style) else { // Font unavailable (CI without fonts); return 0 — the layout will // be width-unconstrained and the container drives the size. return 0.0; @@ -1091,4 +1116,147 @@ mod tests { w ); } + + // ─── #2 / #5: em/% typography resolve against own font-size, not 0 ──── + + fn text_with_style(content: &str, style: CssStyle) -> Text { + Text { + content: content.into(), + max_width: None, + timing: Default::default(), + style, + timeline: Vec::new(), + stagger: None, + text_shadow: None, + stroke: None, + text_background: None, + } + } + + #[test] + fn line_height_percent_no_longer_collapses_the_box_to_zero_height() { + // #2 reproduction: `line-height: "150%"` went through the + // context-free `line_height_for`, which cannot resolve `%` and + // silently fell back to 0 — the intrinsic then reported a + // `line_count * 0.0 = 0` height, so `paint_pass.rs`'s `if height <= + // 0.0 { return }` guard skipped painting the node (and its + // subtree) entirely, even though `validate` reported success. + use rustmotion_core::css::units::LengthPercentage; + let text = text_with_style( + "VISIBLE?", + CssStyle { + font_size: Some(Length::Px(60.0)), + line_height: Some(LineHeight::Length(LengthPercentage::String("150%".into()))), + ..Default::default() + }, + ); + let m = TextIntrinsic::from_text(&text); + let (_w, h) = m.measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + assert!( + (h - 90.0).abs() < 0.5, + "line-height: 150% of a 60px font-size must resolve to 90px (own font-size, per \ + CSS), got {h}" + ); + } + + #[test] + fn line_height_em_no_longer_collapses_the_box_to_zero_height() { + use rustmotion_core::css::units::LengthPercentage; + let text = text_with_style( + "VISIBLE?", + CssStyle { + font_size: Some(Length::Px(60.0)), + line_height: Some(LineHeight::Length(LengthPercentage::String("1.5em".into()))), + ..Default::default() + }, + ); + let m = TextIntrinsic::from_text(&text); + let (_w, h) = m.measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + assert!( + (h - 90.0).abs() < 0.5, + "line-height: 1.5em of a 60px font-size must resolve to 90px, got {h}" + ); + // Sanity: matches the already-correct unitless-number form exactly, + // proving em and the bare-number multiplier agree. + let numeric = text_with_style( + "VISIBLE?", + CssStyle { + font_size: Some(Length::Px(60.0)), + line_height: Some(LineHeight::Number(1.5)), + ..Default::default() + }, + ); + let (_w, h_numeric) = TextIntrinsic::from_text(&numeric).measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + assert_eq!(h, h_numeric); + } + + #[test] + fn letter_spacing_em_matches_the_equivalent_px_measurement() { + // #5 reproduction: `letter-spacing: "1.2em"` at font-size 200 + // (=240px) went through the context-free `letter_spacing_px`, which + // returns 0 for `em` — the intrinsic reserved a box as if tracking + // were 0 while `Text::paint` (which already uses the `_ctx` + // resolver) painted with the real 240px tracking, so `validate`'s + // `unwrappable_text_overflow`/viewport checks (which re-measure via + // this same intrinsic) never saw the real, wider painted width. + let em_style = CssStyle { + font_size: Some(Length::Px(200.0)), + letter_spacing: Some(Length::String("1.2em".into())), + white_space: Some(WhiteSpace::Nowrap), + ..Default::default() + }; + let px_style = CssStyle { + font_size: Some(Length::Px(200.0)), + letter_spacing: Some(Length::Px(240.0)), + white_space: Some(WhiteSpace::Nowrap), + ..Default::default() + }; + let w_em = TextIntrinsic::from_text(&text_with_style("TRACKING", em_style)) + .measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ) + .0; + let w_px = TextIntrinsic::from_text(&text_with_style("TRACKING", px_style)) + .measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ) + .0; + assert!( + (w_em - w_px).abs() < 1.0, + "letter-spacing: 1.2em (font-size 200) must measure the same as the equivalent \ + 240px value: em={w_em}, px={w_px}" + ); + // And it must differ from the old (broken) zero-tracking width — + // otherwise this test would pass vacuously even if em still + // resolved to 0. + let w_zero_tracking = TextIntrinsic::from_text(&text_with_style( + "TRACKING", + CssStyle { + font_size: Some(Length::Px(200.0)), + white_space: Some(WhiteSpace::Nowrap), + ..Default::default() + }, + )) + .measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ) + .0; + assert!( + w_em > w_zero_tracking + 100.0, + "em tracking must measurably widen the line versus zero tracking: em={w_em}, \ + zero={w_zero_tracking}" + ); + } } diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 3b0b023..773bbb8 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -119,8 +119,41 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { return; }; + // The `Painter` contract (traits/painter.rs, rules/paint-context.md) + // promises the canvas is already translated to the CONTENT-box + // origin, with `layout` describing the content box — padding + // reserved by taffy is consumed here, not left for the painter to + // rediscover. `Codeblock` is a deliberate, documented exception: it + // reads `style.padding` itself (`codeblock/render.rs` computes + // `code_x = x + pad_left + gutter_width` from the layout origin it + // receives) and paints its own background/border directly from the + // BORDER-box origin. Honoring the general contract for it too would + // double-apply padding — content shifted twice, background rect + // shrunk incorrectly — so it keeps receiving the untranslated + // border-box origin and dimensions, exactly as before this fix. + let is_self_padding = matches!(child.component, Component::Codeblock(_)); + canvas.save(); - canvas.translate((layout.x, layout.y)); + let local = if is_self_padding { + canvas.translate((layout.x, layout.y)); + BoxLayout { + x: 0.0, + y: 0.0, + width: layout.width, + height: layout.height, + ..Default::default() + } + } else { + let (cx, cy, cw, ch) = layout.content_box(); + canvas.translate((cx, cy)); + BoxLayout { + x: 0.0, + y: 0.0, + width: cw, + height: ch, + ..Default::default() + } + }; let paint_ctx = PaintCtx { time: local_time, @@ -131,13 +164,6 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { video_height: frame.video_height, stagger_offset: stagger_delay, }; - let local = BoxLayout { - x: 0.0, - y: 0.0, - width: layout.width, - height: layout.height, - ..Default::default() - }; painter.paint_content(canvas, &local, &props, &paint_ctx); canvas.restore(); @@ -193,6 +219,83 @@ mod tests { } } + #[test] + fn leaf_painter_content_is_inset_by_padding() { + // A 100x80 red shape at (0,0) with `padding: 20`. The Painter + // contract (traits/painter.rs, rules/paint-context.md) promises the + // canvas is already translated to the CONTENT-box origin — so the + // shape's own fill (which just paints (0,0)..(layout.width, + // layout.height)) should only cover the 60x40 content box (20,20) + // to (80,60), leaving the 20px padding ring showing the (empty/ + // background) canvas underneath. Bug: the dispatcher translated to + // the BORDER-box origin and handed the painter the full border-box + // dimensions, so the fill ignored padding entirely and covered the + // whole (0,0)-(100,80) box. + use rustmotion_core::css::style::Edges; + + let mut scene = vec![shape_child(100.0, 80.0, 0.0, 0.0)]; + if let Component::Shape(s) = &mut scene[0].component { + s.style.padding = Some(Edges::Uniform(CLP::Px(20.0))); + } + let built = build_scene(&scene, (200.0, 200.0)); + let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default()); + + let mut surface = + skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface"); + let canvas = surface.canvas(); + let dispatcher = LegacyPaintDispatcher::new(&built.components); + let frame = PaintFrame { + time: 0.0, + frame_index: 0, + fps: 30, + video_width: 200, + video_height: 200, + scene_duration: 1.0, + camera: None, + }; + rustmotion_core::engine::paint_pass::paint_tree( + canvas, + &built.root, + &layout, + &frame, + &dispatcher, + ); + + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (1, 1), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let read = |x: i32, y: i32| -> [u8; 4] { + let mut buf = [0u8; 4]; + assert!(snapshot.read_pixels( + &info, + &mut buf, + 4, + skia_safe::IPoint::new(x, y), + skia_safe::image::CachingHint::Disallow, + )); + buf + }; + + // Inside the padding ring (5,5): must NOT be red after the fix. + let padding_zone = read(5, 5); + assert!( + !(padding_zone[0] > 200 && padding_zone[1] < 50 && padding_zone[2] < 50), + "padding ring must not be painted by the leaf's own fill, got {:?}", + padding_zone + ); + // Deep inside the content box (50,40): must be red either way. + let content_zone = read(50, 40); + assert!( + content_zone[0] > 200 && content_zone[1] < 50 && content_zone[2] < 50, + "content box must still be painted red, got {:?}", + content_zone + ); + } + #[test] fn dispatch_runs_paint_content_on_leaf() { let scene = vec![shape_child(50.0, 30.0, 10.0, 20.0)]; diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index b053588..20e8092 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -126,19 +126,174 @@ pub use waveform::Waveform; // --- Position mode --- -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +/// Constat #8: `PositionMode::Named(String)` accepts any string, but +/// [`ChildComponent::absolute_position`] only ever treats the literal +/// `"absolute"` specially — every other value (including the CSS-legitimate +/// `"relative"`/`"static"`, which an LLM reasoning in CSS terms naturally +/// reaches for) silently drops `x`/`y`: the component is taken out of flow +/// (`is_flow()` is false for any `Some(position)`) but never receives an +/// absolute offset either, since only `"absolute"` is matched. `x`/`y` are +/// top-level sibling fields on `ChildComponent`, not on `PositionMode` +/// itself, so this can't detect *whether* they were actually set — only +/// that, if they were, they are about to be silently ignored. `"absolute"` +/// stays completely silent (the common, correct case); anything else warns. +pub fn is_recognized_position_name(s: &str) -> bool { + s == "absolute" +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(untagged)] pub enum PositionMode { Absolute { x: f32, y: f32 }, Named(String), } +impl<'de> Deserialize<'de> for PositionMode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Absolute { x: f32, y: f32 }, + Named(String), + } + Ok(match Raw::deserialize(deserializer)? { + Raw::Absolute { x, y } => PositionMode::Absolute { x, y }, + Raw::Named(s) => { + if !is_recognized_position_name(&s) && warn_once_for(&s) { + eprintln!( + "Warning: position: \"{s}\" is not \"absolute\" — this component-level \ + `position` shorthand only honours the literal \"absolute\" (paired with \ + `x`/`y`); any other value, including CSS-legitimate ones like \ + \"relative\"/\"static\", is accepted but silently drops `x`/`y` instead \ + of positioning the element (it still removes the component from flex \ + flow). Use `style.position` for real CSS relative/static semantics." + ); + } + PositionMode::Named(s) + } + }) + } +} + +/// True the first time this exact `position` value is seen, false afterwards. +/// +/// `render_scene_frame` calls `prepare_scene` — and therefore re-runs this +/// `Deserialize` over the whole scene tree — once **per frame**. An unguarded +/// warning here would print the same line once per offending component per +/// frame: over a thousand times on a 1200-frame render, drowning out anything +/// else on stderr. Keyed by the value rather than a plain `Once` so a scenario +/// with several distinct bad values still hears about each of them. +fn warn_once_for(value: &str) -> bool { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + static SEEN: OnceLock>> = OnceLock::new(); + SEEN.get_or_init(Default::default) + .lock() + .map(|mut seen| seen.insert(value.to_owned())) + .unwrap_or(false) +} + impl Default for PositionMode { fn default() -> Self { Self::Absolute { x: 0.0, y: 0.0 } } } +#[cfg(test)] +mod position_mode_tests { + use super::*; + + // ---- constat #8 (RED first) ---- + + #[test] + fn absolute_is_recognized() { + assert!(is_recognized_position_name("absolute")); + } + + #[test] + fn relative_and_static_and_typos_are_not_recognized() { + for s in ["relative", "static", "fixed", "sticky", "Absolute", "abs"] { + assert!( + !is_recognized_position_name(s), + "'{s}' must not be treated as the recognised \"absolute\" value" + ); + } + } + + #[test] + fn absolute_object_form_still_carries_x_y() { + let json = + r#"{ "position": { "x": 10.0, "y": 20.0 }, "type": "shape", "shape": "circle" }"#; + let child: ChildComponent = serde_json::from_str(json).unwrap(); + assert_eq!(child.absolute_position(), Some((10.0, 20.0))); + } + + #[test] + fn absolute_string_form_with_sibling_x_y_still_carries_them() { + let json = + r#"{ "position": "absolute", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#; + let child: ChildComponent = serde_json::from_str(json).unwrap(); + assert_eq!(child.absolute_position(), Some((5.0, 7.0))); + } + + #[test] + fn relative_still_parses_but_drops_x_y_and_the_helper_flags_it() { + // The legitimate-CSS trap named in constat #8: an LLM writes + // `"position": "relative"` (valid CSS) with `x`/`y` alongside it, + // expecting a positioned element. The parse must not fail — this is + // legitimate JSON per the schema's own untagged catch-all — but the + // coordinates are provably dropped (`absolute_position()` is + // `None`), and `is_recognized_position_name` is the named, + // independently testable signal the warning path uses to detect + // this instead of staying silent. + let json = + r#"{ "position": "relative", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#; + let child: ChildComponent = serde_json::from_str(json).unwrap(); + assert!( + !is_recognized_position_name("relative"), + "this is exactly the case the warning fires for" + ); + assert_eq!( + child.absolute_position(), + None, + "x/y are indeed dropped for a non-\"absolute\" position — this is the silent \ + behaviour being made loud, not a new regression" + ); + // The component is still taken out of flow, same as before. + assert!(!child.is_flow()); + } + + /// `prepare_scene` re-runs this `Deserialize` over the whole scene tree + /// once per frame, so the warning must be deduplicated or a 1200-frame + /// render prints it 1200 times. Distinct values still each get a line. + #[test] + fn the_warning_fires_once_per_distinct_value_not_once_per_frame() { + let value = "position-value-used-only-by-this-test"; + assert!(warn_once_for(value), "first sighting must warn"); + for _ in 0..1000 { + assert!( + !warn_once_for(value), + "re-parsing the same value must stay silent" + ); + } + assert!( + warn_once_for("a-different-position-value-for-this-test"), + "a different bad value must still get its own warning" + ); + } + + #[test] + fn no_position_set_is_a_normal_flow_child() { + let json = r#"{ "type": "shape", "shape": "circle" }"#; + let child: ChildComponent = serde_json::from_str(json).unwrap(); + assert!(child.is_flow()); + assert_eq!(child.absolute_position(), None); + } +} + // --- Child wrapper --- #[derive(Debug, Serialize, Deserialize, JsonSchema)] diff --git a/crates/rustmotion-components/src/progress.rs b/crates/rustmotion-components/src/progress.rs index 3a0f0d5..0c0b358 100644 --- a/crates/rustmotion-components/src/progress.rs +++ b/crates/rustmotion-components/src/progress.rs @@ -75,10 +75,10 @@ rustmotion_core::impl_traits!(Progress { }); impl Progress { - fn paint(&self, canvas: &Canvas) -> Result<()> { + fn paint(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> { match self.variant { - ProgressVariant::Linear => self.render_linear(canvas), - ProgressVariant::Circular => self.render_circular(canvas), + ProgressVariant::Linear => self.render_linear(canvas, w, h), + ProgressVariant::Circular => self.render_circular(canvas, w, h), } } } @@ -87,18 +87,24 @@ impl Painter for Progress { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, _ctx: &PaintCtx, ) { - let _ = self.paint(canvas); + // `self.width`/`self.height` only seed the *intrinsic* size in + // `box_builder` (promoted to CSS when `style.width`/`style.height` + // are absent) — the box taffy actually assigns can differ whenever + // an author sets `style.width`/`style.height` or a flex-grow + // idiom directly, which `html-css-mental-model.md` recommends. + // Painting at `self.width`/`self.height` regardless left the fill + // sized to whichever one happened to be smaller, filling only part + // of its own box (or overflowing it) instead of the box. + let _ = self.paint(canvas, layout.width, layout.height); } } impl Progress { - fn render_linear(&self, canvas: &Canvas) -> Result<()> { - let w = self.width; - let h = self.height; + fn render_linear(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> { let radius = self.border_radius; let progress = self.progress.clamp(0.0, 1.0) as f32; @@ -129,14 +135,12 @@ impl Progress { Ok(()) } - fn render_circular(&self, canvas: &Canvas) -> Result<()> { - let w = self.width; - let h = self.height; + fn render_circular(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> { let progress = self.progress.clamp(0.0, 1.0) as f32; let cx = w / 2.0; let cy = h / 2.0; - let radius = cx.min(cy) - self.track_width / 2.0 - 2.0; + let radius = (cx.min(cy) - self.track_width / 2.0 - 2.0).max(0.0); let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0); // Track @@ -192,3 +196,142 @@ impl Progress { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn base_progress(variant: ProgressVariant) -> Progress { + Progress { + progress: 0.5, + variant, + width: default_progress_width(), + height: default_progress_height(), + background_color: default_progress_bg(), + fill_color: default_progress_fill(), + border_radius: 0.0, + track_width: default_track_width(), + show_value: false, + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn base_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scene_duration: 2.0, + frame_index: 0, + fps: 30, + video_width: 900, + video_height: 200, + stagger_offset: 0.0, + } + } + + fn ink_bounds( + surface: &mut skia_safe::Surface, + w: i32, + h: i32, + ) -> Option<(i32, i32, i32, i32)> { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + minx = minx.min(x); + maxx = maxx.max(x); + miny = miny.min(y); + maxy = maxy.max(y); + } + } + } + (minx <= maxx).then_some((minx, maxx, miny, maxy)) + } + + #[test] + fn linear_progress_fills_the_layout_box_not_its_own_width_height() { + // #4's exact repro: `render_linear` always drew at `self.width` x + // `self.height` (defaults 300x20) regardless of the box taffy + // actually assigned it. `box_builder` only promotes `c.width`/ + // `c.height` to CSS when `style.width`/`style.height` are absent — + // so a `progress` sized via `style.width: 800` (the project's + // CSS-first idiom) painted a 300px-wide bar sitting inside an + // 800px-wide box, filling only 37% of it at `progress: 0.5`. + let progress = base_progress(ProgressVariant::Linear); + const BOX_W: f32 = 800.0; + const BOX_H: f32 = 24.0; + let layout = BoxLayout { + width: BOX_W, + height: BOX_H, + ..Default::default() + }; + let ctx = base_ctx(); + let props = AnimatedProperties::default(); + + let mut surface = + skia_safe::surfaces::raster_n32_premul((900, 200)).expect("raster surface"); + { + let canvas = surface.canvas(); + progress.paint_content(canvas, &layout, &props, &ctx); + } + let (_minx, maxx, _miny, _maxy) = + ink_bounds(&mut surface, 900, 200).expect("progress must paint something"); + // At progress 0.5 the fill should reach roughly the middle of the + // 800px box (~400px), not the middle of the component's own + // `width` field (300px -> 150px). + assert!( + maxx as f32 > BOX_W * 0.4, + "fill did not scale to the box's own width: max ink x = {maxx}, box width = {BOX_W}" + ); + } + + #[test] + fn circular_progress_fits_the_layout_box_not_its_own_width_height() { + let progress = base_progress(ProgressVariant::Circular); + const BOX_W: f32 = 60.0; + const BOX_H: f32 = 60.0; + let layout = BoxLayout { + width: BOX_W, + height: BOX_H, + ..Default::default() + }; + let ctx = base_ctx(); + let props = AnimatedProperties::default(); + + let mut surface = + skia_safe::surfaces::raster_n32_premul((300, 300)).expect("raster surface"); + { + let canvas = surface.canvas(); + progress.paint_content(canvas, &layout, &props, &ctx); + } + let (minx, maxx, miny, maxy) = + ink_bounds(&mut surface, 300, 300).expect("progress must paint something"); + // The ring must be centered on the 60x60 box's own center (30, 30), + // not on the component's own `width`/`height` fields' center + // (150, 10 for the 300x20 defaults) — the un-fixed painter puts the + // whole ring outside a small box entirely. + let center_x = (minx + maxx) as f32 / 2.0; + let center_y = (miny + maxy) as f32 / 2.0; + assert!( + (center_x - BOX_W / 2.0).abs() < 5.0 && (center_y - BOX_H / 2.0).abs() < 5.0, + "ring is not centered on the {BOX_W}x{BOX_H} box: center=({center_x}, {center_y})" + ); + } +} diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index 5e58a8d..df6ffc1 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -61,7 +61,15 @@ impl Painter for Shape { .iter() .map(|c| color4f_from_hex(c)) .collect(); - let stops: Option> = gradient.stops.clone(); + // skia asserts `pos.len() == colors.len()` inside the gradient + // shader — a mismatch aborts the process instead of erroring. + // Nothing upstream enforces the pairing, so drop stops we + // cannot honour and let skia distribute the colours evenly. + let stops: Option> = gradient + .stops + .as_ref() + .filter(|s| s.len() == colors.len()) + .cloned(); let mut paint = Paint::default(); paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/sparkline.rs b/crates/rustmotion-components/src/sparkline.rs index a8946d4..e5fde0f 100644 --- a/crates/rustmotion-components/src/sparkline.rs +++ b/crates/rustmotion-components/src/sparkline.rs @@ -60,12 +60,45 @@ rustmotion_core::impl_traits!(Sparkline { Styled => style, }); +/// `(min, max, normalize)` for a min-max scaled series, matching +/// `chart::line::series_scale`'s flat-series handling: a constant series is +/// centred (`0.5`) instead of collapsing to the bottom edge. Dividing by a +/// `max(0.001)` floor mapped a constant series to 0, so a flat series read +/// as "all zero" instead of "constant at some value". Duplicated locally — +/// `chart::line::series_scale` is `pub(super)` to the `chart` module, not +/// reachable from here. +fn series_scale(values: impl Iterator + Clone) -> (f64, f64, impl Fn(f64) -> f32) { + let min_val = values.clone().fold(f64::INFINITY, f64::min); + let max_val = values.fold(f64::NEG_INFINITY, f64::max); + let (min_val, max_val) = if min_val.is_finite() && max_val.is_finite() { + (min_val, max_val) + } else { + (0.0, 0.0) + }; + let span = max_val - min_val; + let flat = span.abs() < f64::EPSILON; + let range = if flat { 1.0 } else { span }; + (min_val, max_val, move |v: f64| { + if flat { + 0.5 + } else { + ((v - min_val) / range) as f32 + } + }) +} + impl Sparkline { fn progress_at(&self, time: f64) -> f32 { if !self.animated { return 1.0; } - let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32; + // Ramp measured from `start_at`, not from scene time zero — matches + // `Counter::ramp_progress`. A sparkline delayed with `start_at` used + // to read raw scene time, so it was already fully revealed on the + // very first frame it became visible. + let start = self.timing.start_at.unwrap_or(0.0); + let elapsed = (time - start).max(0.0); + let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; 1.0 - (1.0 - p).powi(3) } @@ -79,9 +112,7 @@ impl Sparkline { let progress = self.progress_at(time); - let max_val = self.data.iter().fold(f64::MIN, |a, &b| a.max(b)); - let min_val = self.data.iter().fold(f64::MAX, |a, &b| a.min(b)); - let range = (max_val - min_val).max(0.001); + let (_, _, norm) = series_scale(self.data.iter().copied()); let pad = self.stroke_width; @@ -90,7 +121,7 @@ impl Sparkline { for (i, &val) in self.data.iter().enumerate() { let x = pad + (i as f32 / (n - 1) as f32) * (w - pad * 2.0); - let y = pad + (h - pad * 2.0) - ((val - min_val) / range) as f32 * (h - pad * 2.0); + let y = pad + (h - pad * 2.0) - norm(val) * (h - pad * 2.0); if i == 0 { line_path.move_to((x, y)); @@ -166,3 +197,101 @@ impl Painter for Sparkline { self.paint(canvas, layout.width, layout.height, ctx.time); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::traits::TimingConfig; + + fn base_sparkline(data: Vec) -> Sparkline { + Sparkline { + data, + color: default_color(), + fill: false, + fill_opacity: default_fill_opacity(), + stroke_width: default_stroke_width(), + animated: true, + animation_duration: 1.0, + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn ink_bounds( + surface: &mut skia_safe::Surface, + w: i32, + h: i32, + ) -> Option<(i32, i32, i32, i32)> { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + minx = minx.min(x); + maxx = maxx.max(x); + miny = miny.min(y); + maxy = maxy.max(y); + } + } + } + (minx <= maxx).then_some((minx, maxx, miny, maxy)) + } + + #[test] + fn a_flat_series_is_centered_not_pinned_to_the_bottom_edge() { + // #7's exact repro: with every value equal, `(val - min_val)` is + // 0 for every point, so the line was drawn on the bottom edge + // (`h - pad`) — reading as "a series of zeroes" instead of "a + // constant series at some value". `chart::line::series_scale` + // was fixed to center a flat series (0.5) for exactly this reason. + const H: i32 = 40; + let flat = base_sparkline(vec![7.0, 7.0, 7.0, 7.0, 7.0]); + let mut surface = skia_safe::surfaces::raster_n32_premul((120, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + flat.paint(canvas, 120.0, H as f32, 10.0); + } + let (_minx, _maxx, miny, maxy) = + ink_bounds(&mut surface, 120, H).expect("flat sparkline must still paint a line"); + let mid = (miny + maxy) as f32 / 2.0; + let bottom_edge = H as f32 - flat.stroke_width; + assert!( + (mid - H as f32 / 2.0).abs() < 6.0, + "flat series line should sit near vertical center (y~{}), got y=[{miny}..{maxy}]", + H / 2 + ); + assert!( + (bottom_edge - maxy as f32).abs() > 6.0, + "flat series line must not be pinned to the bottom edge: y=[{miny}..{maxy}], bottom={bottom_edge}" + ); + } + + #[test] + fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() { + let mut sparkline = base_sparkline(vec![1.0, 2.0, 3.0]); + sparkline.animation_duration = 1.5; + sparkline.timing = TimingConfig { + start_at: Some(2.0), + end_at: None, + }; + assert_eq!(sparkline.progress_at(2.0), 0.0); + assert!(sparkline.progress_at(2.75) < 1.0); + assert_eq!(sparkline.progress_at(3.5), 1.0); + } +} diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs index e1b3b0d..740ed5d 100644 --- a/crates/rustmotion-components/src/stat.rs +++ b/crates/rustmotion-components/src/stat.rs @@ -323,7 +323,12 @@ impl Stat { let max_v = self.sparkline_data.iter().fold(f64::MIN, |a, &b| a.max(b)); let min_v = self.sparkline_data.iter().fold(f64::MAX, |a, &b| a.min(b)); - let range = (max_v - min_v).max(0.001); + // A flat series has no span. Flooring the divisor instead normalises + // every point to 0, which glues the line to the bottom edge and reads + // as "collapsed to zero" rather than "unchanged" — centre it instead, + // matching `chart::line`'s handling of the same case. + let span = max_v - min_v; + let flat = span.abs() < f64::EPSILON; let n = self.sparkline_data.len(); let spark_color = self.sparkline_color.as_deref().unwrap_or("#3B82F6"); @@ -333,7 +338,8 @@ impl Stat { for (i, &val) in self.sparkline_data.iter().enumerate() { let x = pad + (i as f32 / (n - 1) as f32) * spark_w; - let y = spark_y + spark_h - ((val - min_v) / range) as f32 * spark_h; + let norm = if flat { 0.5 } else { (val - min_v) / span }; + let y = spark_y + spark_h - norm as f32 * spark_h; if i == 0 { line_path.move_to((x, y)); diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 806eb99..0c0bfab 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -146,7 +146,14 @@ impl Table { let text_color = self.style.color_str_or("#FFFFFF"); let header_text_color = self.header_text_color.as_deref().unwrap_or("#FFFFFF"); let default_row_colors = vec!["#1F2937".to_string(), "#111827".to_string()]; - let row_colors = self.row_colors.as_ref().unwrap_or(&default_row_colors); + // `"row_colors": []` deserializes to Some(vec![]), not None — a generator + // writes it to mean "no striping". Row painting indexes this slice, so an + // empty one has to fall back rather than reach the painter. + let row_colors = self + .row_colors + .as_ref() + .filter(|c| !c.is_empty()) + .unwrap_or(&default_row_colors); // Resolve fonts before the optional clip below so an early return on // font failure keeps canvas save/restore balanced. diff --git a/crates/rustmotion-components/src/tag_cloud.rs b/crates/rustmotion-components/src/tag_cloud.rs index eedad5a..93fdf21 100644 --- a/crates/rustmotion-components/src/tag_cloud.rs +++ b/crates/rustmotion-components/src/tag_cloud.rs @@ -79,11 +79,12 @@ impl TagCloud { 1.0 - (1.0 - p).powi(3) } + /// Never empty: callers index into it with `%`, and `"colors": []` — which a + /// generator emits to mean "no custom palette" — otherwise divides by zero. fn palette(&self) -> Vec<&str> { - if let Some(colors) = &self.colors { - colors.iter().map(|s| s.as_str()).collect() - } else { - DEFAULT_PALETTE.to_vec() + match &self.colors { + Some(colors) if !colors.is_empty() => colors.iter().map(|s| s.as_str()).collect(), + _ => DEFAULT_PALETTE.to_vec(), } } diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs index 668e176..0e1d8ce 100644 --- a/crates/rustmotion-components/src/terminal.rs +++ b/crates/rustmotion-components/src/terminal.rs @@ -6,7 +6,8 @@ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::{ease, AnimatedProperties}; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ - draw_text_with_fallback, emoji_typeface, paint_from_hex, typeface_with_fallback, + draw_text_with_fallback, emoji_typeface, paint_from_hex, resolve_custom_typeface, + typeface_with_fallback, }; use rustmotion_core::schema::{CodeblockReveal, RevealMode, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -130,10 +131,24 @@ pub(crate) const FONT_SIZE: f32 = 14.0; pub(crate) const LINE_HEIGHT: f32 = 22.0; pub(crate) const PADDING: f32 = 16.0; +/// The typeface both the painter and the intrinsic measurement resolve. +/// +/// These must never diverge. The measurement reserves the box the painter then +/// fills, so a different face on either side produces text that overflows a box +/// the geometry validator has already declared safe — the exact failure mode the +/// audit found across the text components. Both sides used to hardcode +/// `"SF Mono"` independently, which agreed only by coincidence and ignored an +/// explicit `font-family` outright, custom or not. +pub(crate) fn resolve_typeface(style: &CssStyle) -> Option { + let font_style = skia_safe::FontStyle::normal(); + let family = style.font_family_or("SF Mono"); + resolve_custom_typeface(family, font_style) + .or_else(|| typeface_with_fallback(family, font_style).ok()) +} + impl Terminal { fn make_font(&self) -> Option { - let font_style = skia_safe::FontStyle::normal(); - let typeface = typeface_with_fallback("SF Mono", font_style).ok()?; + let typeface = resolve_typeface(&self.style)?; let size = self.style.font_size_px_or(FONT_SIZE); Some(skia_safe::Font::from_typeface(typeface, size)) } diff --git a/crates/rustmotion-components/src/treemap.rs b/crates/rustmotion-components/src/treemap.rs index b29195d..fb7872b 100644 --- a/crates/rustmotion-components/src/treemap.rs +++ b/crates/rustmotion-components/src/treemap.rs @@ -113,7 +113,13 @@ impl Treemap { if !self.animated { return 1.0; } - let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32; + // Ramp measured from `start_at`, not from scene time zero — matches + // `Counter::ramp_progress`. A treemap delayed with `start_at` used + // to read raw scene time, so it was already fully scaled in on the + // very first frame it became visible. + let start = self.timing.start_at.unwrap_or(0.0); + let elapsed = (time - start).max(0.0); + let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; 1.0 - (1.0 - p).powi(3) } @@ -182,11 +188,35 @@ impl Treemap { // Labels if self.show_labels || self.show_values { - if scaled_rect.width() < 30.0 || scaled_rect.height() < 20.0 { + let mut text_parts: Vec = vec![]; + if self.show_labels { + if let Some(label) = &item.label { + text_parts.push(label.clone()); + } + } + if self.show_values { + text_parts.push(format!("{}", item.value)); + } + + if text_parts.is_empty() { continue; } let font_size = (scaled_rect.width() * 0.12).clamp(10.0, 24.0); + // `draw_text_with_fallback` builds a single-line `TextBlob` + // (renderer/text.rs) — joining label and value with "\n" + // never produced a line break, it fed the blob a literal + // control glyph. Each part now gets its own baseline, and + // the space each line needs (`20.0` per line, same floor + // the old single-line check used) is checked before + // drawing instead of after. + let line_height = font_size * 1.2; + if scaled_rect.width() < 30.0 + || scaled_rect.height() < 20.0 * text_parts.len() as f32 + { + continue; + } + let font = skia_safe::Font::from_typeface(&typeface, font_size); let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); @@ -194,35 +224,29 @@ impl Treemap { let mut text_paint = paint_from_hex("#FFFFFF"); text_paint.set_anti_alias(true); - let mut text_parts: Vec = vec![]; - if self.show_labels { - if let Some(label) = &item.label { - text_parts.push(label.clone()); - } - } - if self.show_values { - text_parts.push(format!("{}", item.value)); - } - - let text = text_parts.join("\n"); - let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0); let (_, metrics) = font.metrics(); - - let text_x = scaled_rect.left + (scaled_rect.width() - text_w) / 2.0; - let text_y = scaled_rect.top - + scaled_rect.height() / 2.0 - + (-metrics.ascent - metrics.descent) / 2.0; - - draw_text_with_fallback( - canvas, - &text, - &font, - &emoji_font, - 0.0, - text_x, - text_y, - &text_paint, - ); + let ascent = -metrics.ascent; + let descent = metrics.descent; + let block_h = line_height * text_parts.len() as f32; + let block_top = scaled_rect.top + scaled_rect.height() / 2.0 - block_h / 2.0; + + for (li, part) in text_parts.iter().enumerate() { + let part_w = measure_text_with_fallback(part, &font, &emoji_font, 0.0); + let part_x = scaled_rect.left + (scaled_rect.width() - part_w) / 2.0; + let line_center_y = block_top + (li as f32 + 0.5) * line_height; + let part_y = line_center_y + (ascent - descent) / 2.0; + + draw_text_with_fallback( + canvas, + part, + &font, + &emoji_font, + 0.0, + part_x, + part_y, + &text_paint, + ); + } } } } @@ -239,3 +263,169 @@ impl Painter for Treemap { self.paint(canvas, layout.width, layout.height, ctx.time); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::traits::TimingConfig; + + fn base_treemap(data: Vec) -> Treemap { + Treemap { + data, + gap: default_gap(), + border_radius: default_border_radius(), + show_labels: default_show_labels(), + show_values: false, + animated: true, + animation_duration: 1.0, + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn read_rgba(surface: &mut skia_safe::Surface, w: i32, h: i32) -> Vec { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + buf + } + + /// A pixel is "text ink" if it's opaque-ish and near-white — the fixed + /// `#FFFFFF` label/value color, distinct from the cell's own colored + /// (never white) `DEFAULT_PALETTE` background fill that would otherwise + /// dominate a naive alpha-only scan. + fn is_text_ink(buf: &[u8], w: i32, x: i32, y: i32) -> bool { + let idx = ((y * w + x) * 4) as usize; + let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]); + a > 40 && r > 200 && g > 200 && b > 200 + } + + /// Contiguous vertical bands (start_y, end_y) of text ink, merging rows + /// separated by a 1px anti-aliasing gap but splitting on anything + /// wider — used to tell "two stacked text lines" apart from "one line + /// of text". + fn row_bands(buf: &[u8], w: i32, h: i32) -> Vec<(i32, i32)> { + let mut bands: Vec<(i32, i32)> = vec![]; + for y in 0..h { + let has_ink = (0..w).any(|x| is_text_ink(buf, w, x, y)); + if has_ink { + match bands.last_mut() { + Some((_, end)) if y <= *end + 1 => *end = y, + _ => bands.push((y, y)), + } + } + } + bands + } + + fn row_ink_x_range(buf: &[u8], w: i32, y0: i32, y1: i32) -> (i32, i32) { + let (mut minx, mut maxx) = (i32::MAX, i32::MIN); + for y in y0..=y1 { + for x in 0..w { + if is_text_ink(buf, w, x, y) { + minx = minx.min(x); + maxx = maxx.max(x); + } + } + } + (minx, maxx) + } + + #[test] + fn label_and_value_render_on_two_separate_centered_lines() { + // #8's exact repro: `text_parts.join("\n")` fed a single-line + // `TextBlob` a literal "\n" glyph — the label and value landed side + // by side on the same baseline instead of stacked, and the whole + // (wrongly wide) string was centered as one block, decentering the + // label itself. + const W: i32 = 300; + const H: i32 = 200; + let mut treemap = base_treemap(vec![TreemapItem { + label: Some("Alpha".to_string()), + value: 50.0, + color: None, + }]); + treemap.show_labels = true; + treemap.show_values = true; + treemap.animated = false; + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + treemap.paint(canvas, W as f32, H as f32, 0.0); + } + let buf = read_rgba(&mut surface, W, H); + let bands = row_bands(&buf, W, H); + assert_eq!( + bands.len(), + 2, + "label and value must render as two stacked lines, got bands={bands:?}" + ); + for (y0, y1) in bands { + let (minx, maxx) = row_ink_x_range(&buf, W, y0, y1); + let center = (minx + maxx) as f32 / 2.0; + assert!( + (center - W as f32 / 2.0).abs() < 12.0, + "line y=[{y0}..{y1}] is not centered on the box: ink x center = {center}" + ); + } + } + + #[test] + fn a_single_label_still_renders_as_one_centered_line() { + const W: i32 = 300; + const H: i32 = 200; + let mut treemap = base_treemap(vec![TreemapItem { + label: Some("Alpha".to_string()), + value: 50.0, + color: None, + }]); + treemap.show_labels = true; + treemap.show_values = false; + treemap.animated = false; + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + treemap.paint(canvas, W as f32, H as f32, 0.0); + } + let buf = read_rgba(&mut surface, W, H); + let bands = row_bands(&buf, W, H); + assert_eq!( + bands.len(), + 1, + "a single label is one line, got bands={bands:?}" + ); + } + + #[test] + fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() { + let mut treemap = base_treemap(vec![TreemapItem { + label: None, + value: 1.0, + color: None, + }]); + treemap.animation_duration = 1.5; + treemap.timing = TimingConfig { + start_at: Some(2.0), + end_at: None, + }; + assert_eq!(treemap.progress_at(2.0), 0.0); + assert!(treemap.progress_at(2.75) < 1.0); + assert_eq!(treemap.progress_at(3.5), 1.0); + } +} diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 6b5a680..44183f4 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -19,10 +19,16 @@ const H: u32 = 300; /// Renders a single caption component (absolutely positioned at y=150 so the /// baseline-anchored glyphs are fully on-canvas) and returns the RGBA buffer. fn render_caption(json: serde_json::Value, time: f64) -> Vec { + render_caption_at(json, time, 150.0) +} + +/// Same as [`render_caption`] but with an explicit vertical position, for +/// tests whose caption spans more lines than fit below the default y=150. +fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { let component: Component = serde_json::from_value(json).expect("deserialize caption"); let child = ChildComponent { component, - position: Some(PositionMode::Absolute { x: 0.0, y: 150.0 }), + position: Some(PositionMode::Absolute { x: 0.0, y }), x: None, y: None, z_index: None, @@ -193,3 +199,65 @@ fn karaoke_pop_highlights_active_word_with_pill() { "inactive words not visible: {inactive} white pixels" ); } + +/// Audit finding #1: a caption with no `max_width` set, given a box +/// narrower than its unwrapped content via `style.width` (mirrors a caption +/// placed inside a card, the documented use case), must wrap to fit that +/// box — not paint one wide line that bleeds out of it. Routed through the +/// real pipeline so `CaptionIntrinsic` (the box taffy reserves) and +/// `Caption::paint` (what actually gets drawn) are exercised together: this +/// is exactly the measure-vs-paint pairing the geometry validator depends +/// on to catch overflow, and before the fix the two disagreed silently. +#[test] +fn wraps_within_its_layout_box_when_max_width_is_unset() { + let json = serde_json::json!({ + "type": "caption", + "mode": "highlight", + "words": [ + { "text": "the", "start": 0.0, "end": 100.0 }, + { "text": "quick", "start": 0.0, "end": 100.0 }, + { "text": "brown", "start": 0.0, "end": 100.0 }, + { "text": "fox", "start": 0.0, "end": 100.0 }, + { "text": "jumps", "start": 0.0, "end": 100.0 }, + { "text": "over", "start": 0.0, "end": 100.0 }, + { "text": "the", "start": 0.0, "end": 100.0 }, + { "text": "lazy", "start": 0.0, "end": 100.0 }, + { "text": "dog", "start": 0.0, "end": 100.0 } + ], + // No `max_width` — the box comes entirely from `style.width` below, + // mirroring a caption inside a fixed-width card. + "style": { "width": "150px", "font-size": 24, "color": "#FFFFFF" } + }); + let buf = render_caption_at(json, 0.5, 20.0); + let (minx, maxx, miny, maxy) = ink_bounds(&buf).expect("caption must paint something"); + + assert!( + maxx - minx < 200, + "must wrap to roughly the 150px box width, got ink width {}", + maxx - minx + ); + assert!( + maxy - miny > 60, + "must spread across multiple lines (9 words don't fit 150px on one line at 30px \ + font-size), got ink height {}", + maxy - miny + ); +} + +/// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent pixel +/// in an RGBA8888 `W`x`H` buffer, or `None` if nothing was painted. +fn ink_bounds(buf: &[u8]) -> Option<(i32, i32, i32, i32)> { + let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); + for y in 0..H as i32 { + for x in 0..W as i32 { + let idx = ((y * W as i32 + x) * 4 + 3) as usize; + if buf[idx] > 0 { + minx = minx.min(x); + maxx = maxx.max(x); + miny = miny.min(y); + maxy = maxy.max(y); + } + } + } + (minx <= maxx).then_some((minx, maxx, miny, maxy)) +} diff --git a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs new file mode 100644 index 0000000..80bcabe --- /dev/null +++ b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs @@ -0,0 +1,141 @@ +//! Pixel test for codeblock `auto_scroll` during a typewriter reveal +//! (audit finding #4). +//! +//! Routes a `codeblock` through the real pipeline (box_builder, run_layout, +//! paint_tree) exactly like `caption_presets.rs` does, and counts painted +//! (non-background) pixels at several points during a typewriter reveal. +//! Before the fix, `scroll_offset` was computed from the *full* code's +//! natural height regardless of how many lines the reveal had actually +//! painted, so the box stayed empty for the majority of the reveal — the +//! newly-revealed lines were translated above the clip and invisible until +//! the reveal caught up with that constant offset. + +use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; +use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; +use rustmotion_components::{ChildComponent, Component, PositionMode}; +use rustmotion_core::css::taffy_bridge::ConversionContext; +use rustmotion_core::engine::layout_pass::run_layout; +use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + +const W: u32 = 1000; +const H: u32 = 600; +const SCENE_DURATION: f64 = 4.5; + +fn codeblock_json() -> serde_json::Value { + let lines: Vec = (0..40).map(|i| format!("let line_{i} = {i};")).collect(); + let code = lines.join("\n"); + serde_json::json!({ + "type": "codeblock", + "code": code, + "language": "rust", + "auto_scroll": true, + "reveal": { "mode": "typewriter", "start": 0.0, "duration": 4.0 }, + "style": { "width": "900px", "height": "300px", "font-size": 20 } + }) +} + +/// Count pixels whose color differs measurably from the codeblock's own +/// dark background (`#2b303b` when unset) — i.e. actual glyph ink, not just +/// "anything non-transparent" (the background rect itself is opaque and +/// covers the whole box). +fn text_ink_pixels(buf: &[u8]) -> usize { + // Background is #2b303b ~ (43, 48, 59). Count pixels that deviate from + // that by a wide margin in any channel — syntect's theme colors are all + // much brighter than the near-black background. + buf.chunks_exact(4) + .filter(|p| { + let (r, g, b, a) = (p[0] as i32, p[1] as i32, p[2] as i32, p[3] as i32); + a > 200 && ((r - 43).abs() > 40 || (g - 48).abs() > 40 || (b - 59).abs() > 40) + }) + .count() +} + +fn render_codeblock_at(time: f64) -> Vec { + let component: Component = serde_json::from_value(codeblock_json()).expect("deserialize"); + let child = ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 50.0, y: 150.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }; + let children = vec![child]; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + + let built = build_scene_with_anim( + &children, + (W as f32, H as f32), + BuildAnimationCtx { + time, + scene_duration: SCENE_DURATION, + fps: 30, + }, + ); + let layout = run_layout( + &built.root, + (W as f32, H as f32), + &ConversionContext::default(), + ); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + time, + frame_index: (time * 30.0) as u32, + fps: 30, + video_width: W, + video_height: H, + scene_duration: SCENE_DURATION, + camera: None, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); + + let row_bytes = W as usize * 4; + let mut pixels = vec![0u8; row_bytes * H as usize]; + let info = skia_safe::ImageInfo::new( + (W as i32, H as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels +} + +#[test] +fn early_reveal_shows_text_not_an_empty_box() { + // At t=0.5s (12.5% into a 4s typewriter reveal, well past the first + // line), some text must already be visible — before the fix, this + // stayed at 0 ink pixels until ~75% of the reveal had elapsed. + let ink = text_ink_pixels(&render_codeblock_at(0.5)); + assert!( + ink > 20, + "expected visible text ink early in the reveal (t=0.5s), got {ink} ink pixels" + ); +} + +#[test] +fn mid_reveal_shows_text_not_an_empty_box() { + // t=2.0s: 50% into the reveal — well within the range the audit + // measured as a completely empty box (0 ink pixels at t=0.5/1.0/2.0). + let ink = text_ink_pixels(&render_codeblock_at(2.0)); + assert!( + ink > 20, + "expected visible text ink mid-reveal (t=2.0s), got {ink} ink pixels" + ); +} + +#[test] +fn ink_grows_monotonically_enough_across_the_reveal() { + // Sanity: as more of the reveal completes, more text should be on + // screen (loosely monotonic — auto_scroll keeps only the visible + // window, so it won't be strictly increasing forever, but the box must + // never regress to empty once text has started appearing). + let t_early = text_ink_pixels(&render_codeblock_at(0.5)); + let t_late = text_ink_pixels(&render_codeblock_at(3.9)); + assert!(t_early > 0, "must have visible ink at t=0.5s, got 0"); + assert!(t_late > 0, "must have visible ink at t=3.9s, got 0"); +} diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs new file mode 100644 index 0000000..8b609f3 --- /dev/null +++ b/crates/rustmotion-components/tests/degenerate_inputs.rs @@ -0,0 +1,185 @@ +//! A painter must never panic — nor hang — on input the schema accepts. +//! +//! `rustmotion validate` is the documented gate before delivery, and it answers +//! "Valid scenario" for every component below. Each one was observed aborting or +//! wedging the renderer mid-frame, which kills the whole encode: the scenarios +//! here are the reproductions, verbatim, kept as a regression floor. +//! +//! Every case routes through the real pipeline — serde, box_builder, run_layout, +//! paint_tree — so a fix that only guards the painter while leaving the component +//! undeserialisable would still fail. + +use std::sync::mpsc; +use std::time::Duration; + +use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; +use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; +use rustmotion_components::{ChildComponent, Component, PositionMode}; +use rustmotion_core::css::taffy_bridge::ConversionContext; +use rustmotion_core::engine::layout_pass::run_layout; +use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + +const W: u32 = 400; +const H: u32 = 300; + +/// Deserialize one component and paint it at `time`. Panics propagate — that is +/// the point of the test. +fn paint(json: serde_json::Value, time: f64) { + let component: Component = serde_json::from_value(json).expect("component is schema-valid"); + let children = vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + + let built = build_scene_with_anim( + &children, + (W as f32, H as f32), + BuildAnimationCtx { + time, + scene_duration: 2.0, + fps: 30, + }, + ); + let layout = run_layout( + &built.root, + (W as f32, H as f32), + &ConversionContext::default(), + ); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + time, + frame_index: (time * 30.0) as u32, + fps: 30, + video_width: W, + video_height: H, + scene_duration: 2.0, + camera: None, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); +} + +/// Paint on a worker so a runaway loop fails the test instead of wedging the +/// suite. `dot_spacing: 0` used to spin for billions of iterations; a plain +/// `paint()` call would hang CI rather than report. +fn paint_within(json: serde_json::Value, time: f64, budget: Duration, what: &str) { + let (tx, rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + paint(json, time); + let _ = tx.send(()); + }); + match rx.recv_timeout(budget) { + Ok(()) => worker.join().expect(what), + Err(_) => panic!("{what}: still painting after {budget:?} — runaway loop"), + } +} + +#[test] +fn shape_survives_a_gradient_whose_stops_do_not_match_its_colors() { + // skia asserts pos.len() == colors.len() inside the gradient shader, so a + // mismatched `stops` aborted the process rather than returning an error. + for gradient_type in ["linear", "radial"] { + paint( + serde_json::json!({ + "type": "shape", + "shape": "rect", + "style": { "width": 200, "height": 100 }, + "fill": { + "type": gradient_type, + "colors": ["#FF0000", "#0000FF"], + "stops": [0.0, 0.5, 1.0] + } + }), + 0.5, + ); + } +} + +#[test] +fn table_survives_an_empty_row_colors_list() { + // `row_colors: []` deserializes to Some(vec![]), so the default palette was + // never substituted and the modulo guard still indexed an empty slice. + paint( + serde_json::json!({ + "type": "table", + "headers": ["A", "B"], + "rows": [["1", "2"], ["3", "4"]], + "row_colors": [], + "style": { "width": 300, "height": 200 } + }), + 0.5, + ); +} + +#[test] +fn tag_cloud_survives_an_empty_colors_list() { + // palette() returned the caller's empty vec, and the painter took + // `index % palette.len()` on it. + paint( + serde_json::json!({ + "type": "tag_cloud", + "tags": [{ "text": "rust", "weight": 3 }, { "text": "skia", "weight": 1 }], + "colors": [], + "style": { "width": 300, "height": 200 } + }), + 0.5, + ); +} + +#[test] +fn dot_map_terminates_on_a_zero_dot_spacing() { + // (w - 0) / 0 is +inf, and `inf as u32` saturates to u32::MAX in Rust, so the + // nested loop was scheduled for ~1.8e19 iterations. The geometry pass catches + // dot_spacing: 0.01 but not 0. + paint_within( + serde_json::json!({ + "type": "dot_map", + "points": [{ "lat": 48.8, "lng": 2.3 }], + "dot_spacing": 0, + "style": { "width": 300, "height": 150 } + }), + 0.5, + Duration::from_secs(10), + "dot_map with dot_spacing: 0", + ); +} + +#[test] +fn codeblock_diff_survives_multibyte_text() { + // The edit script counts bytes while the reveal interpolates a fraction of + // that count, so mid-animation offsets landed inside a multi-byte character + // and `replace_range` aborted with "not a char boundary". + for (from, to) in [ + ("let a = 1;", "let café = «héllo→»;"), + ("let x = 1;", "let y = \"éàü\";"), + ("a", "日本語のテキスト"), + ("ok", "🎬 clap"), + ] { + // Sweep the reveal: the panic only fires on the frames where progress + // lands part-way through a glyph. + for step in 0..=20 { + paint( + serde_json::json!({ + "type": "codeblock", + "code": from, + "language": "rust", + "diff": true, + "states": [ + { "code": from, "at": 0.0 }, + { "code": to, "at": 0.4, "cursor": { "enabled": true } } + ], + "style": { "width": 380, "height": 120 } + }), + f64::from(step) * 0.1, + ); + } + } +} diff --git a/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs b/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs new file mode 100644 index 0000000..3a2547e --- /dev/null +++ b/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs @@ -0,0 +1,111 @@ +//! Regression test — audit round 4, lot LAYOUT, constat 5. +//! +//! `.claude/skills/rustmotion/rules/html-css-mental-model.md` — a rules file +//! read by the LLMs that generate rustmotion scenarios — documented +//! `"margin-top"` / `"margin-left"` as valid per-child JSON keys, and +//! `"padding": [40, 60]` as a valid CSS-shorthand-style array. Neither is: +//! `CssStyle` only exposes `margin: Option` / `padding: Option` +//! (`#[serde(deny_unknown_fields)]`), and `Edges` is `Uniform(LengthPercentage)` +//! or a `{top, right, bottom, left}` object — never a per-key kebab-case field, +//! never an array. Following the old doc verbatim makes the whole component +//! fail typed deserialization, so it silently disappears from the render +//! instead of being spaced as intended. +//! +//! These tests pin both failure modes (so a future change can't quietly +//! re-introduce them) and confirm the corrected syntax the doc now teaches +//! actually round-trips. + +use rustmotion_components::Component; + +fn component_json_parses(json: serde_json::Value) -> bool { + serde_json::from_value::(json).is_ok() +} + +#[test] +fn old_doc_margin_top_shorthand_fails_to_deserialize() { + // What the doc used to teach (html-css-mental-model.md, old line 87): + // `"margin-top": 16` as a direct style key. + let json = serde_json::json!({ + "type": "text", + "content": "hi", + "style": { "margin-top": 16 } + }); + let err = serde_json::from_value::(json) + .expect_err("margin-top must not deserialize — printed below for the audit red-phase log"); + eprintln!("captured deserialize error (constat 5 red phase): {err}"); + assert!( + err.to_string().contains("margin-top") || err.to_string().contains("unknown field"), + "expected an unknown-field error mentioning the rejected key, got: {err}" + ); +} + +#[test] +fn old_doc_margin_left_auto_shorthand_fails_to_deserialize() { + // What the doc used to teach (html-css-mental-model.md, old line 90): + // `"margin-left": "auto"` as a direct style key. + let json = serde_json::json!({ + "type": "badge", + "text": "NEW", + "style": { "margin-left": "auto" } + }); + assert!( + !component_json_parses(json), + "`margin-left` is not a CssStyle field — same failure mode as margin-top" + ); +} + +#[test] +fn old_doc_padding_array_shorthand_fails_to_deserialize() { + // What the doc used to teach (html-css-mental-model.md, old line 177): + // `"padding": [40, 60]`, a CSS `padding: 40px 60px` -style array shorthand + // `Edges` does not implement. + let json = serde_json::json!({ + "type": "card", + "style": { "padding": [40, 60] }, + "children": [] + }); + assert!( + !component_json_parses(json), + "`padding` only accepts a uniform scalar or a {{top,right,bottom,left}} \ + object, never an array — the old doc's example should fail" + ); +} + +#[test] +fn corrected_margin_object_syntax_round_trips() { + let json = serde_json::json!({ + "type": "text", + "content": "hi", + "style": { "margin": { "top": 16 } } + }); + assert!( + component_json_parses(json), + "the doc's corrected `\"margin\": {{\"top\": 16}}` syntax must actually parse" + ); +} + +#[test] +fn corrected_margin_left_auto_object_syntax_round_trips() { + let json = serde_json::json!({ + "type": "badge", + "text": "NEW", + "style": { "margin": { "left": "auto" } } + }); + assert!( + component_json_parses(json), + "the doc's corrected `\"margin\": {{\"left\": \"auto\"}}` syntax must actually parse" + ); +} + +#[test] +fn corrected_padding_object_syntax_round_trips() { + let json = serde_json::json!({ + "type": "card", + "style": { "padding": { "top": 40, "bottom": 40, "left": 60, "right": 60 } }, + "children": [] + }); + assert!( + component_json_parses(json), + "the doc's corrected per-side `padding` object syntax must actually parse" + ); +} diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index ad44f35..a37ef79 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -486,12 +486,21 @@ pub enum Visibility { } /// `width: ` / `width: auto` / `width: 50%` / `width: max-content` / etc. +/// +/// Constat #6: `#[serde(untagged)]` tries variants in declaration order and +/// keeps the first that succeeds. `Length(LengthPercentage)` has its own +/// `String` catch-all variant that accepts *any* string — so with `Keyword` +/// declared after `Length` (as this used to be), `"max-content"` matched +/// `Length(String("max-content"))` before `Keyword` was ever tried: +/// `max-content`/`min-content`/`fit-content` were unreachable, dead schema. +/// `Keyword` must come before the `Length` catch-all; `Auto` before either +/// is fine since it needs an exact `"auto"` match nothing else claims first. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum Size { Auto(AutoKw), - Length(LengthPercentage), Keyword(SizeKeyword), + Length(LengthPercentage), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -509,8 +518,19 @@ pub enum SizeKeyword { } /// Edge values for `margin` / `padding`. Either uniform or per-side. +/// +/// Constat #2: `CssStyle` itself has `deny_unknown_fields`, which gives the +/// impression that any bad key under `style` is rejected — but one level +/// down, `Sides`'s four fields are all `#[serde(default)]` with no +/// `deny_unknown_fields` of its own. Since this is an untagged enum, a +/// well-meaning but unsupported shape like `{"horizontal": 20}` (the exact +/// form the LAYOUT `margin-left` rule teaches LLMs to reach for) fails to +/// match `Uniform` (not a scalar) and then matches `Sides` anyway — every +/// side defaults to 0, no error. `deny_unknown_fields` here closes that: an +/// object that isn't a recognised `{top,right,bottom,left}` shape now fails +/// to match either variant, and the untagged enum reports it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] +#[serde(untagged, deny_unknown_fields)] pub enum Edges { Uniform(LengthPercentage), Sides { @@ -579,18 +599,35 @@ pub enum BorderStyle { } /// Border-radius: uniform or per-corner. +/// +/// Constat #1: every other composite in this file is kebab-case on the wire +/// (`box-shadow` -> `offset-x`/`offset-y`, `transform-origin` -> `x`/`y`, +/// etc. — see `rules/component-field-placement.md`). `Corners` used to be +/// the sole snake_case outlier (`top_left`/...), with no `deny_unknown_fields` +/// and every field defaulted — so the kebab form a CSS-literate author (or +/// LLM) naturally writes matched *zero* declared fields, and being an +/// untagged enum, serde didn't complain: it just produced `Corners` with +/// every corner at 0px, silently. `rename_all = "kebab-case"` makes kebab +/// the canonical wire form (matching every neighbour); `alias` keeps the +/// original snake_case working for any scenario already written that way; +/// `deny_unknown_fields` turns any other spelling (a genuine typo) into a +/// named parse error instead of a third silent zero. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] +#[serde(untagged, deny_unknown_fields)] pub enum BorderRadius { Uniform(LengthPercentage), Corners { - #[serde(default)] + #[serde(default, alias = "top_left")] + #[serde(rename = "top-left")] top_left: LengthPercentage, - #[serde(default)] + #[serde(default, alias = "top_right")] + #[serde(rename = "top-right")] top_right: LengthPercentage, - #[serde(default)] + #[serde(default, alias = "bottom_right")] + #[serde(rename = "bottom-right")] bottom_right: LengthPercentage, - #[serde(default)] + #[serde(default, alias = "bottom_left")] + #[serde(rename = "bottom-left")] bottom_left: LengthPercentage, }, } @@ -786,12 +823,20 @@ pub enum FontStyle { } /// `line-height: 1.5` (number) or `line-height: 24px` (length). +/// +/// Same class of bug as constat #6 on [`Size`], found while auditing this +/// file for other untagged enums with a catch-all before a specific variant: +/// `Length(LengthPercentage)`'s `String` fallback accepts any string, so +/// with `Keyword` declared after it, `"normal"` matched +/// `Length(String("normal"))` — which then resolves through +/// `Length::px()`/`.parse()` as an unparseable length, falling back to 0 — +/// instead of `Keyword(LineHeightKw::Normal)`. `Keyword` now comes first. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum LineHeight { Number(f32), - Length(LengthPercentage), Keyword(LineHeightKw), + Length(LengthPercentage), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -1497,4 +1542,177 @@ mod tests { ); assert_eq!(line_height, 300.0 * 0.85); } + + // ---- constat #1: border-radius per-corner kebab-case (RED first) ---- + + #[test] + fn border_radius_corners_accepts_kebab_case() { + // This is the shape every sibling composite in this file uses + // (box-shadow -> offset-x/offset-y, transform-origin -> x/y, etc.) + // and the shape `rules/component-field-placement.md` teaches. Before + // the fix, `BorderRadius::Corners`'s fields are literally + // `top_left`/`top_right`/... with no kebab alias, so this kebab + // object fails to match `Corners` (unknown fields) and, being all + // `#[serde(default)]`, matches it anyway with every corner at 0 — + // the untagged enum never reports an error, it just silently + // produces radius 0. + let json = r#"{ "border-radius": { "top-left": "12px", "top-right": "12px", "bottom-right": "4px", "bottom-left": "4px" } }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + match s.border_radius { + Some(BorderRadius::Corners { + top_left, + top_right, + bottom_right, + bottom_left, + }) => { + assert_eq!(top_left.px(), 12.0, "top-left must be honoured, not 0"); + assert_eq!(top_right.px(), 12.0); + assert_eq!(bottom_right.px(), 4.0); + assert_eq!(bottom_left.px(), 4.0); + } + other => panic!("expected Corners, got {other:?}"), + } + } + + #[test] + fn border_radius_corners_still_accepts_legacy_snake_case() { + // Back-compat: any scenario already written with the old + // snake_case field names must keep working identically. + let json = r#"{ "border-radius": { "top_left": "8px", "top_right": "8px", "bottom_right": "8px", "bottom_left": "8px" } }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + assert_eq!(s.border_radius_px(), Some(8.0)); + } + + #[test] + fn border_radius_corners_typo_is_a_named_error_not_a_silent_zero() { + // A misspelled key must not silently resolve to Corners{0,0,0,0} — + // it must be reported. + let json = r#"{ "border-radius": { "topleft": "12px" } }"#; + let err = serde_json::from_str::(json).expect_err("typo must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("topleft") + || msg.contains("border-radius") + || msg.contains("BorderRadius"), + "error must name the offending input, got: {msg}" + ); + } + + // ---- constat #2: `Edges` (padding/margin) rejects unknown shapes (RED first) ---- + + #[test] + fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() { + // `rules/margin-left-hack.md`-adjacent trap: an LLM reasoning in CSS + // terms writes `{"horizontal": 20}` instead of the supported + // `{"top":.., "right":.., "bottom":.., "left":..}` shape. Before the + // fix, `Edges::Sides`'s four fields are all `#[serde(default)]` with + // no `deny_unknown_fields`, so this object matches `Sides` anyway + // with every side at 0 — silent, wrong padding instead of an error. + let json = r#"{ "padding": { "horizontal": 20 } }"#; + let err = serde_json::from_str::(json) + .expect_err("an unrecognised padding shape must be rejected, not silently zeroed"); + let msg = err.to_string(); + assert!( + msg.contains("horizontal") || msg.contains("padding") || msg.contains("Edges"), + "error must name the offending input, got: {msg}" + ); + } + + #[test] + fn edges_still_accepts_valid_per_side_object() { + let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + assert_eq!(s.padding_px(), (10.0, 20.0, 10.0, 20.0)); + } + + #[test] + fn edges_still_accepts_uniform_scalar() { + let json = r#"{ "padding": "24px" }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + assert_eq!(s.padding_px(), (24.0, 24.0, 24.0, 24.0)); + } + + // ---- constat #6: `Size` untagged variant order (RED first) ---- + + #[test] + fn size_keyword_max_content_is_reachable() { + // `Size` is `#[serde(untagged)]`: Auto, Length, Keyword in that + // declared order (before the fix). `Length(LengthPercentage)`'s + // `String` fallback variant accepts *any* string, so it is tried + // (and succeeds) before `Keyword` is ever reached — `max-content` / + // `min-content` / `fit-content` are dead schema. After the fix, + // `Keyword` must be tried before the `Length` catch-all. + for (kw, expected) in [ + ("max-content", SizeKeyword::MaxContent), + ("min-content", SizeKeyword::MinContent), + ("fit-content", SizeKeyword::FitContent), + ] { + let json = format!(r#"{{ "width": "{kw}" }}"#); + let s: CssStyle = serde_json::from_str(&json).unwrap(); + assert_eq!( + s.width, + Some(Size::Keyword(expected)), + "width: \"{kw}\" must resolve to Size::Keyword, not Size::Length(String(..))" + ); + } + } + + #[test] + fn size_length_and_auto_are_unaffected_by_the_reorder() { + let s: CssStyle = serde_json::from_str(r#"{ "width": "200px" }"#).unwrap(); + assert!(matches!(s.width, Some(Size::Length(_)))); + let s: CssStyle = serde_json::from_str(r#"{ "width": "50%" }"#).unwrap(); + assert!(matches!(s.width, Some(Size::Length(_)))); + let s: CssStyle = serde_json::from_str(r#"{ "width": "auto" }"#).unwrap(); + assert!(matches!(s.width, Some(Size::Auto(_)))); + let s: CssStyle = serde_json::from_str(r#"{ "width": 200 }"#).unwrap(); + assert!(matches!(s.width, Some(Size::Length(_)))); + } + + // ---- extra: `LineHeight` has the same catch-all-before-specific shape + // as constat #6's `Size`, found while auditing this file for the same + // bug class. Fixed alongside it (see the doc comment on `LineHeight`). + + #[test] + fn line_height_keyword_normal_is_reachable() { + let s: CssStyle = serde_json::from_str(r#"{ "line-height": "normal" }"#).unwrap(); + assert_eq!( + s.line_height, + Some(LineHeight::Keyword(LineHeightKw::Normal)), + "line-height: \"normal\" must resolve to Keyword, not Length(String(\"normal\"))" + ); + } + + #[test] + fn line_height_number_and_length_are_unaffected_by_the_reorder() { + let s: CssStyle = serde_json::from_str(r#"{ "line-height": 1.5 }"#).unwrap(); + assert!(matches!(s.line_height, Some(LineHeight::Number(_)))); + let s: CssStyle = serde_json::from_str(r#"{ "line-height": "24px" }"#).unwrap(); + assert!(matches!(s.line_height, Some(LineHeight::Length(_)))); + } + + // ---- border-radius: kebab-case is the canonical wire form on output ---- + + #[test] + fn border_radius_corners_serializes_as_kebab_case() { + let s = CssStyle { + border_radius: Some(BorderRadius::Corners { + top_left: LengthPercentage::Px(1.0), + top_right: LengthPercentage::Px(2.0), + bottom_right: LengthPercentage::Px(3.0), + bottom_left: LengthPercentage::Px(4.0), + }), + ..Default::default() + }; + let json = serde_json::to_value(&s).unwrap(); + let br = &json["border-radius"]; + assert_eq!(br["top-left"], serde_json::json!(1.0)); + assert_eq!(br["top-right"], serde_json::json!(2.0)); + assert_eq!(br["bottom-right"], serde_json::json!(3.0)); + assert_eq!(br["bottom-left"], serde_json::json!(4.0)); + assert!( + br.get("top_left").is_none(), + "must not emit the legacy snake_case key any more" + ); + } } diff --git a/crates/rustmotion-core/src/css/taffy_bridge.rs b/crates/rustmotion-core/src/css/taffy_bridge.rs index c7a27b9..9376ebb 100644 --- a/crates/rustmotion-core/src/css/taffy_bridge.rs +++ b/crates/rustmotion-core/src/css/taffy_bridge.rs @@ -9,9 +9,9 @@ use taffy::prelude as tf; use super::style::{ - AlignContent, AlignItems, AlignSelf, CssStyle, Display, Edges, FlexDirection, FlexWrap, Gap, - GridAutoFlow, GridLine, GridLineEnd, GridTrack, GridTrackKeyword, JustifyContent, Overflow, - Position, Size, + AlignContent, AlignItems, AlignSelf, BoxSizing, CssStyle, Display, Edges, FlexDirection, + FlexWrap, Gap, GridAutoFlow, GridLine, GridLineEnd, GridTrack, GridTrackKeyword, + JustifyContent, JustifyItems, JustifySelf, Overflow, Position, Size, }; use super::units::{LengthContext, LengthPercentage, ParsedLength}; @@ -65,6 +65,18 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { }; style.aspect_ratio = css.aspect_ratio; + // `box-sizing` (round 4 audit, lot LAYOUT, constat 3): taffy supports it + // natively (`Style::box_sizing`, default `BorderBox`) — schema-valid but + // untranslated before this fix, so `content-box` was silently ignored + // and every sized box behaved as `border-box` regardless of what the + // author declared. + if let Some(bs) = css.box_sizing { + style.box_sizing = match bs { + BoxSizing::ContentBox => tf::BoxSizing::ContentBox, + BoxSizing::BorderBox => tf::BoxSizing::BorderBox, + }; + } + // Margin / padding / border (border WIDTH only — border style/color are paint props) style.margin = edges_to_rect_lpa(css.margin.as_ref(), ctx); style.padding = edges_to_rect_lp(css.padding.as_ref(), ctx); @@ -114,6 +126,29 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { if let Some(basis) = css.flex_basis.as_ref() { style.flex_basis = size_to_dim(Some(basis), ctx); } + // `order` (round 4 audit, lot LAYOUT, constat 3): schema-valid but has no + // taffy equivalent — taffy has no flex/grid item-reordering primitive + // (its internal `order` on `Layout` is source order, assigned during + // layout, not settable via `Style`). Translating is not possible, so — + // per the same "fail loud instead of a silent no-op" contract this + // module's `Length`/`LengthPercentage` parsing already uses (see + // `units.rs`'s `px_or_warn` / `parse_length_or_warn`) — warn instead of + // dropping it without a trace. Reorder the JSON `children` array itself + // to get the equivalent effect. + // Emitted at most once per process: `to_taffy_style` runs per node per + // layout pass, and layout runs per frame — an unguarded `eprintln!` here + // would print the same line a thousand times over a single render and + // slow it down while doing so. + if css.order.is_some() { + static WARNED_ORDER: std::sync::Once = std::sync::Once::new(); + WARNED_ORDER.call_once(|| { + eprintln!( + "Warning: `order` is not supported by the layout engine (no flex/grid item \ + reordering primitive) — it is ignored. Reorder the component's JSON `children` \ + array instead to change paint/layout order." + ); + }); + } // Gap if let Some(gap) = css.gap.as_ref() { @@ -154,6 +189,17 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { if let Some(gr) = css.grid_row.as_ref() { style.grid_row = grid_placement_line(gr); } + // `justify-items` / `justify-self` (round 4 audit, lot LAYOUT, constat 3): + // taffy supports both natively for grid children, reusing the same + // `AlignItems`/`AlignSelf` types as `align-items`/`align-self` (the + // block-axis equivalents) — same untranslated-but-schema-valid gap as + // `box-sizing` above. + if let Some(ji) = css.justify_items { + style.justify_items = Some(justify_items_to_taffy(ji)); + } + if let Some(js) = css.justify_self { + style.justify_self = justify_self_to_taffy(js); + } // Overflow if let Some(o) = css.overflow { @@ -191,6 +237,33 @@ fn align_self_to_taffy(a: AlignSelf) -> Option { }) } +fn justify_items_to_taffy(j: JustifyItems) -> tf::AlignItems { + match j { + JustifyItems::Stretch => tf::AlignItems::Stretch, + JustifyItems::Start => tf::AlignItems::Start, + JustifyItems::End => tf::AlignItems::End, + JustifyItems::Center => tf::AlignItems::Center, + // `legacy` (old CSS2-era grid keyword, only meaningful combined with + // `left`/`right`/`center` which this schema doesn't expose) has no + // taffy analog; `Start` is the closest normal-flow behaviour and + // matches this bridge's own `Auto`-ish fallbacks elsewhere. + JustifyItems::Legacy => tf::AlignItems::Start, + } +} + +fn justify_self_to_taffy(j: JustifySelf) -> Option { + Some(match j { + // `auto` computes to the parent's `justify-items` — `None` is + // exactly how this bridge already models `align-self: auto` + // inheriting `align-items` above. + JustifySelf::Auto => return None, + JustifySelf::Stretch => tf::AlignSelf::Stretch, + JustifySelf::Start => tf::AlignSelf::Start, + JustifySelf::End => tf::AlignSelf::End, + JustifySelf::Center => tf::AlignSelf::Center, + }) +} + fn align_content_to_taffy(a: AlignContent) -> tf::AlignContent { match a { AlignContent::Stretch => tf::AlignContent::Stretch, @@ -724,4 +797,58 @@ mod tests { "each 1fr column should be ~300px wide, got {w1}" ); } + + // ── Round 4 audit, lot LAYOUT, constat 3: box-sizing / justify-items / + // justify-self are schema-valid but were never translated to taffy. ──── + + #[test] + fn box_sizing_content_box_is_translated() { + let css = CssStyle { + box_sizing: Some(BoxSizing::ContentBox), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.box_sizing, tf::BoxSizing::ContentBox); + } + + #[test] + fn box_sizing_defaults_to_border_box() { + let css = CssStyle::default(); + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.box_sizing, tf::BoxSizing::BorderBox); + } + + #[test] + fn justify_items_is_translated_for_grid_children() { + let css = CssStyle { + display: Some(Display::Grid), + justify_items: Some(JustifyItems::Center), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.justify_items, Some(tf::AlignItems::Center)); + } + + #[test] + fn justify_self_is_translated() { + let css = CssStyle { + justify_self: Some(JustifySelf::End), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.justify_self, Some(tf::AlignSelf::End)); + } + + #[test] + fn justify_self_auto_falls_back_to_parent_justify_items() { + // `auto` computes to the parent's `justify-items` — taffy models + // this the same way `align-self: auto` models inheriting + // `align-items`: `None`, not an explicit `Start`. + let css = CssStyle { + justify_self: Some(JustifySelf::Auto), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.justify_self, None); + } } diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index ecba7cf..fb07e20 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -43,8 +43,23 @@ pub struct ResolvedCharAnimation { /// Extracted and categorized animation effects from an AnimationEffect slice. pub struct ExtractedEffects<'a> { pub presets: Vec<(AnimationPreset, PresetConfig)>, - pub keyframes: Vec<&'a Animation>, - pub owned_keyframes: Vec, + /// Every `keyframes`/`tilt_in` effect's animations, in the order their + /// source effects appear in `style.animation` (constat #5: this used to + /// be split into two buckets — routed purely by whether the effect's + /// `delay` happened to be nonzero — resolved and merged separately, + /// which made "sum vs last-wins" on a shared property depend on that + /// unrelated field. Now there is one bucket, resolved in one + /// `resolve_animations` call, so the composition rule is always + /// "last effect in the array wins on a shared property" — a CSS-cascade + /// rule, independent of `delay`). + pub keyframe_animations: Vec, + /// True when any contributing `keyframes`/`tilt_in` effect requested + /// `"loop": true` (constat #7). Applied uniformly to the whole + /// `keyframe_animations` bucket — see the doc comment on + /// `resolve_props_for_effects` for the same caveat presets already have + /// (multiple effects with different loop settings on the same property + /// is an unsupported edge case, not new to this fix). + pub keyframes_loop: bool, pub wiggles: Vec<&'a WiggleConfig>, pub orbits: Vec<&'a OrbitConfig>, pub glow: Option<&'a GlowConfig>, @@ -73,8 +88,8 @@ pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> { pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { let mut result = ExtractedEffects { presets: Vec::new(), - keyframes: Vec::new(), - owned_keyframes: Vec::new(), + keyframe_animations: Vec::new(), + keyframes_loop: false, wiggles: Vec::new(), orbits: Vec::new(), glow: None, @@ -122,24 +137,23 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { result.orbits.push(config); } AnimationEffect::Keyframes(config) => { - if config.delay.abs() > 1e-9 { - // Keyframe times are absolute scene seconds; the - // config-level delay shifts them (it used to be - // silently ignored, breaking timeline/stagger shifts - // on keyframes effects). - result - .owned_keyframes - .extend(config.keyframes.iter().map(|anim| { - let mut a = anim.clone(); - for kf in &mut a.keyframes { - kf.time += config.delay; - } - a - })); - } else { - for kf in &config.keyframes { - result.keyframes.push(kf); - } + // Keyframe times are absolute scene seconds; the + // config-level delay shifts them (applied unconditionally + // — a no-op when `delay == 0` — so every `keyframes` + // effect lands in the same bucket regardless of its + // delay; see the `ExtractedEffects::keyframe_animations` + // doc comment for why that used to matter). + result + .keyframe_animations + .extend(config.keyframes.iter().map(|anim| { + let mut a = anim.clone(); + for kf in &mut a.keyframes { + kf.time += config.delay; + } + a + })); + if config.repeat { + result.keyframes_loop = true; } } AnimationEffect::TiltIn(config) => { @@ -149,7 +163,7 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { let ry = config.rotate_y.unwrap_or(-15.0); let persp = config.perspective.unwrap_or(1000.0); let sc = config.scale_from.unwrap_or(0.9); - result.owned_keyframes.extend([ + result.keyframe_animations.extend([ kf_anim( "opacity", delay, @@ -163,6 +177,9 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { kf_anim("perspective", delay, persp, end, persp, EasingType::Linear), kf_anim("scale", delay, sc, end, 1.0, EasingType::EaseOutCubic), ]); + if config.repeat { + result.keyframes_loop = true; + } } AnimationEffect::MotionBlur(config) => { result.motion_blur = Some(config.intensity); @@ -322,10 +339,22 @@ fn ease_in_out_cubic(t: f64) -> f64 { /// Solve spring animation at time t (seconds). /// Returns a value between 0.0 and 1.0 representing progress. +/// +/// Constat #6: `SpringConfig` accepts any `f64` (it's schema-level, not +/// range-checked at parse time), and `rustmotion validate` used to check +/// nothing about it either. `mass <= 0` or `stiffness <= 0` fed straight into +/// `sqrt`/division below produced NaN (sqrt of a negative/undefined ratio, +/// or division by zero), and negative `damping` flipped the decay +/// exponent's sign so the "settling" oscillation diverged to +-infinity +/// instead. Either poisons every transform/opacity value downstream once it +/// merges into `AnimatedProperties`. `validate_schema.rs` now rejects these +/// combinations as errors (belt), and this floor keeps the solver itself +/// finite and bounded even if an out-of-band caller skips validation +/// (suspenders) — see `spring_robustness_tests` below. pub fn spring_value(t: f64, config: &SpringConfig) -> f64 { - let damping = config.damping; - let stiffness = config.stiffness; - let mass = config.mass; + let damping = config.damping.max(0.0); + let stiffness = config.stiffness.max(1e-6); + let mass = config.mass.max(1e-6); let omega = (stiffness / mass).sqrt(); let zeta = damping / (2.0 * (stiffness * mass).sqrt()); @@ -534,15 +563,27 @@ pub fn resolve_props_for_effects( let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration); props.merge(&p); } - // owned_keyframes (generated by TiltIn etc.) are merged first so that explicit - // user keyframes take priority via the multiplicative merge() semantics. - if !extracted.owned_keyframes.is_empty() { - let kp = resolve_animations(&extracted.owned_keyframes, None, None, time, scene_duration); - props.merge(&kp); - } - if !extracted.keyframes.is_empty() { - let kf: Vec = extracted.keyframes.iter().copied().cloned().collect(); - let kp = resolve_animations(&kf, None, None, time, scene_duration); + // Every `keyframes`/`tilt_in` effect is resolved together in one call + // (constat #5): within a single `resolve_animations` call, multiple + // `Animation`s targeting the same property are applied in list order via + // `apply_property` (assignment, not addition), so the *last* effect in + // `style.animation` wins on a shared property — deterministic, and + // independent of any effect's `delay`. `keyframes_loop` (constat #7) + // carries `"loop": true` from any contributing effect into the solver, + // which `resolve_animations` used to never see (it was always called + // with `preset_config = None`, i.e. `repeat = false`). + if !extracted.keyframe_animations.is_empty() { + let loop_cfg = PresetConfig { + repeat: extracted.keyframes_loop, + ..Default::default() + }; + let kp = resolve_animations( + &extracted.keyframe_animations, + None, + Some(&loop_cfg), + time, + scene_duration, + ); props.merge(&kp); } if !extracted.wiggles.is_empty() { @@ -1283,16 +1324,34 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim_loop("scale", 0.95, 1.05)], - AnimationPreset::Float => vec![kf_anim_3kf( + // `delay`/`duration` used to be decorative here: the keyframes were + // pinned to literal times 0.0/0.25/0.5/1.0 regardless of what the + // scenario authored (constat #2), so every pulsing/floating/shaking/ + // spinning element in a scene shared one hardcoded 1-second cycle + // starting at t=0. `delay` now shifts the cycle's start and + // `duration` sets its length, exactly like every other preset. + AnimationPreset::Pulse => vec![kf_anim_3kf_over( + "scale", + delay, + end, + 0.95, + 1.05, + 0.95, + EasingType::EaseInOut, + )], + AnimationPreset::Float => vec![kf_anim_3kf_over( "position.y", + delay, + end, 0.0, -10.0, 0.0, EasingType::EaseInOut, )], - AnimationPreset::Shake => vec![kf_anim_4kf( + AnimationPreset::Shake => vec![kf_anim_4kf_over( "position.x", + delay, + end, 0.0, 10.0, -10.0, @@ -1301,9 +1360,9 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim( "rotation", + delay, 0.0, - 0.0, - 1.0, + end, 360.0, EasingType::Linear, )], @@ -1393,10 +1452,41 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec Animation { - Animation { - property: property.to_string(), - keyframes: vec![kf(0.0, v0), kf(0.5, v1), kf(1.0, v2)], - easing, - spring: None, - } -} - -fn kf_anim_4kf( +/// Four-keyframe oscillation (quarter/half/end split) laid out over an +/// explicit `start..end` window — the `shake` counterpart to +/// `kf_anim_3kf_over`. +#[allow(clippy::too_many_arguments)] +fn kf_anim_4kf_over( property: &str, + start: f64, + end: f64, v0: f64, v1: f64, v2: f64, v3: f64, easing: EasingType, ) -> Animation { + let quarter = (end - start) / 4.0; Animation { property: property.to_string(), - keyframes: vec![kf(0.0, v0), kf(0.25, v1), kf(0.5, v2), kf(1.0, v3)], + keyframes: vec![ + kf(start, v0), + kf(start + quarter, v1), + kf(start + quarter * 2.0, v2), + kf(end, v3), + ], easing, spring: None, } } -fn kf_anim_loop(property: &str, min: f64, max: f64) -> Animation { - Animation { - property: property.to_string(), - keyframes: vec![kf(0.0, min), kf(0.5, max), kf(1.0, min)], - easing: EasingType::EaseInOut, - spring: None, - } -} - #[cfg(test)] mod spring_preset_tests { //! TDD tests for issue #88: spring easing on any preset via @@ -1793,3 +1877,386 @@ mod glow_tests { ); } } + +#[cfg(test)] +mod float3d_amplitude_tests { + //! Constat #1: `PresetConfig::amplitude` is read by `expand_preset_inner` + //! (`config.amplitude.unwrap_or(12.0)`) but `AnimationTiming::to_preset_config` + //! used to hardcode `amplitude: None`, so any author-supplied amplitude on + //! a `float_3d` effect never reached the solver — every element bobbed by + //! the same hardcoded 12px regardless of what was authored. + use super::*; + use crate::schema::AnimationEffect; + + /// Peak absolute `translate_y` reached while sampling densely across one + /// cycle — proxy for the oscillation's amplitude actually resolved. + fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 { + let mut peak = 0.0f64; + let steps = 200; + for i in 0..=steps { + let t = window * i as f64 / steps as f64; + let y = resolve_props_for_effects(effects, t, window + 1.0).translate_y as f64; + if y.abs() > peak.abs() { + peak = y; + } + } + peak + } + + #[test] + fn author_supplied_amplitude_reaches_the_solver() { + // Parsed from raw JSON, not built in Rust — proves the value survives + // serde all the way to the resolver, not merely that the struct has a + // field for it. + let default_fx: AnimationEffect = + serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap(); + let big_fx: AnimationEffect = + serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0, "amplitude": 60 }"#) + .unwrap(); + + let default_peak = peak_translate_y(&[default_fx], 1.0); + let big_peak = peak_translate_y(&[big_fx], 1.0); + + assert!( + (default_peak.abs() - 12.0).abs() < 0.5, + "default float_3d amplitude must stay ~12px, got {default_peak}" + ); + assert!( + big_peak.abs() > 50.0, + "amplitude=60 must reach the solver (peak translate_y near 60px), got {big_peak} \ + (default was {default_peak})" + ); + } +} + +#[cfg(test)] +mod continuous_preset_timing_tests { + //! Constat #2: `pulse` / `float` / `shake` / `spin` used to fabricate + //! keyframes at literal times 0.0/0.25/0.5/1.0, ignoring `config.delay` + //! and `config.duration` entirely — every element sharing one of these + //! presets moved in lockstep on a fixed 1-second cycle no matter what the + //! scenario authored. + use super::*; + use crate::schema::AnimationEffect; + + fn timing(delay: f64, duration: f64) -> AnimationTimingFixture { + AnimationTimingFixture { delay, duration } + } + + /// Minimal JSON round-trip helper — keeps every case going through serde, + /// like the author's JSON would. + struct AnimationTimingFixture { + delay: f64, + duration: f64, + } + + impl AnimationTimingFixture { + fn json(&self, name: &str) -> String { + format!( + r#"{{ "name": "{}", "delay": {}, "duration": {} }}"#, + name, self.delay, self.duration + ) + } + } + + #[test] + fn pulse_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap(); + // Before its delay, the cycle has not started: the resolver clamps to + // the first keyframe's value (the 0.95 trough) at every pre-delay + // instant — it must be identical at two different pre-delay times, + // not moving. Before the fix, delay/duration were ignored and the + // preset ran its own literal 0..1s cycle regardless, so t=0.1 and + // t=0.9 fell in different oscillation phases and disagreed. + let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64; + let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64; + assert!( + (early - late).abs() < 1e-6, + "pulse must be frozen before its delay=1.0 (not yet oscillating): \ + t=0.1 -> {early}, t=0.9 -> {late}" + ); + assert!( + (early - 0.95).abs() < 0.01, + "pulse before its delay must clamp to the first keyframe (0.95), got {early}" + ); + // At the midpoint of its cycle (delay + duration/2 = 2.0): near the peak (1.05). + let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64; + assert!( + mid > 1.03, + "pulse at t=2.0 (cycle midpoint) must be near peak scale ~1.05, got {mid}" + ); + } + + #[test] + fn float_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("float")).unwrap(); + let before = + resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_y as f64; + assert!( + before.abs() < 0.1, + "float at t=0.5 (before delay=1.0) must be at rest y=0, got {before}" + ); + let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).translate_y as f64; + assert!( + mid < -8.0, + "float at t=2.0 (cycle midpoint) must be near peak y=-10, got {mid}" + ); + } + + #[test] + fn shake_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("shake")).unwrap(); + let before = + resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_x as f64; + assert!( + before.abs() < 0.1, + "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}" + ); + // Quarter point of the cycle (delay + duration/4 = 1.5): near +10 peak. + let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64; + assert!( + quarter > 8.0, + "shake at t=1.5 (cycle quarter) must be near peak x=+10, got {quarter}" + ); + } + + #[test] + fn spin_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("spin")).unwrap(); + let before = + resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).rotation as f64; + assert!( + before.abs() < 0.1, + "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}" + ); + // Halfway through its own cycle (delay + duration/2 = 2.0): ~180deg. + let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64; + assert!( + (mid - 180.0).abs() < 5.0, + "spin at t=2.0 (cycle midpoint) must be near 180deg, got {mid}" + ); + } +} + +#[cfg(test)] +mod keyframes_composition_tests { + //! Constat #5: two `keyframes` effects targeting the same property used + //! to be routed into one of two buckets purely by whether `delay != 0` + //! (`owned_keyframes` vs `keyframes` in `extract_effects`), each bucket + //! resolved by its own `resolve_animations` call and combined via + //! `AnimatedProperties::merge` — which *sums* additive properties like + //! `translate_x` across buckets, while two effects landing in the *same* + //! bucket instead overwrite (last one in the list wins, since + //! `apply_property` assigns rather than adds). So the composition rule + //! depended entirely on an incidental field (`delay`) with no relation to + //! authoring intent. + //! + //! Chosen semantic: every `keyframes`/`tilt_in` effect is resolved + //! together in one `resolve_animations` call, in the order the effects + //! appear in `style.animation` — like a CSS cascade, the *last* effect + //! in the array wins on a shared property. This is deterministic and + //! independent of `delay`. + use super::*; + use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig}; + + /// A `keyframes` effect with one property ramping `0 -> value` over + /// `[0, 1]` (pre-shift), then shifted by `delay`. + fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect { + AnimationEffect::Keyframes(KeyframesConfig { + keyframes: vec![Animation { + property: property.to_string(), + keyframes: vec![ + Keyframe { + time: 0.0, + value: KeyframeValue::Number(0.0), + easing: None, + }, + Keyframe { + time: 1.0, + value: KeyframeValue::Number(value), + easing: None, + }, + ], + easing: EasingType::Linear, + spring: None, + }], + delay, + duration: 0.8, + repeat: false, + }) + } + + #[test] + fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() { + // Case 1: A (delay=0) declared first, B (delay=0.5) declared second. + let a1 = ramp("translate_x", 100.0, 0.0); + let b1 = ramp("translate_x", 40.0, 0.5); + let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64; + let b1_alone = resolve_props_for_effects(&[b1], 1.0, 5.0).translate_x as f64; + assert!( + (combined_1 - b1_alone).abs() < 1e-4, + "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}" + ); + + // Case 2: swap which one carries the delay, keep declaration order + // (A first, B second) — the outcome must be identical in shape: B + // (still last) wins alone, this time using B's own (now delay=0) + // timing. + let a2 = ramp("translate_x", 100.0, 0.5); + let b2 = ramp("translate_x", 40.0, 0.0); + let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64; + let b2_alone = resolve_props_for_effects(&[b2], 1.0, 5.0).translate_x as f64; + assert!( + (combined_2 - b2_alone).abs() < 1e-4, + "B (declared last) must alone determine translate_x at t=1.0 even with delay swapped: \ + combined={combined_2}, B-alone={b2_alone}" + ); + + // The two cases must NOT collapse to the same number (sanity check + // that this test isn't vacuous — B's own resolved value genuinely + // differs between the two delay assignments). + assert!( + (combined_1 - combined_2).abs() > 1.0, + "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}" + ); + } +} + +#[cfg(test)] +mod keyframes_loop_tests { + //! Constat #7: `"loop": true` on a `keyframes` effect or on `tilt_in` + //! never reached the solver. `resolve_props_for_effects` always called + //! `resolve_animations(&kfs, None, None, ...)` for both keyframe buckets + //! — passing `preset_config = None` means `resolve_animations` falls back + //! to `PresetConfig::default()`, whose `repeat` is `false`, so + //! `loop_time` was never invoked no matter what `KeyframesConfig::repeat` + //! / `TiltInConfig::repeat` said. + use super::*; + use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig}; + + #[test] + fn keyframes_loop_true_wraps_time_past_the_last_keyframe() { + let looping = AnimationEffect::Keyframes(KeyframesConfig { + keyframes: vec![Animation { + property: "opacity".to_string(), + keyframes: vec![ + Keyframe { + time: 0.0, + value: KeyframeValue::Number(0.0), + easing: None, + }, + Keyframe { + time: 1.0, + value: KeyframeValue::Number(1.0), + easing: None, + }, + ], + easing: EasingType::Linear, + spring: None, + }], + delay: 0.0, + duration: 0.8, + repeat: true, + }); + // t=2.5 is past the keyframe's own last time (1.0). Without looping, + // the resolver clamps to the last keyframe's value (1.0) forever. + // With looping (start=0, end=1, duration=1), t=2.5 wraps to 0.5 -> + // opacity should be ~0.5, not 1.0. + let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64; + assert!( + (opacity - 0.5).abs() < 0.05, + "looping keyframes at t=2.5 must wrap to local t=0.5 (opacity ~0.5), got {opacity}" + ); + } + + #[test] + fn tilt_in_loop_true_keeps_tilting_past_its_settle_time() { + let looping_tilt: AnimationEffect = serde_json::from_str( + r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4, "loop": true }"#, + ) + .unwrap(); + let settled: AnimationEffect = + serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#) + .unwrap(); + + // Well past the settle time (0.4s): without loop, scale is pinned at + // the final resting value (1.0). With loop (cycle 0..0.4), t=1.0 + // wraps to local t=0.2 (t=1.0 % 0.4 = 0.2), mid-tilt, scale != 1.0. + let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64; + let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64; + + assert!( + (settled_scale - 1.0).abs() < 1e-3, + "non-looping tilt_in at t=1.0 (past settle) must be resting at scale 1.0, got {settled_scale}" + ); + assert!( + (looping_scale - 1.0).abs() > 0.01, + "looping tilt_in at t=1.0 must still be mid-cycle (scale != 1.0 rest), got {looping_scale}" + ); + } +} + +#[cfg(test)] +mod spring_robustness_tests { + //! Constat #6: `spring_value` fed `mass`/`stiffness`/`damping` straight + //! into `sqrt`/division with no floor, so `mass <= 0` or `stiffness <= 0` + //! produced NaN (division by zero or sqrt of a negative number), and + //! negative `damping` flipped the decay exponent's sign, diverging to + //! +-infinity instead of settling. A NaN/inf progress value then flows + //! into transform math (translate/scale) and contaminates the whole + //! subtree it touches. + use super::*; + + #[test] + fn zero_mass_does_not_produce_nan() { + let config = SpringConfig { + damping: 10.0, + stiffness: 100.0, + mass: 0.0, + }; + for i in 0..=20 { + let t = i as f64 * 0.25; + let v = spring_value(t, &config); + assert!( + v.is_finite(), + "spring_value(t={t}) with mass=0 must be finite, got {v}" + ); + } + } + + #[test] + fn zero_stiffness_does_not_produce_nan() { + let config = SpringConfig { + damping: 10.0, + stiffness: 0.0, + mass: 1.0, + }; + for i in 0..=20 { + let t = i as f64 * 0.25; + let v = spring_value(t, &config); + assert!( + v.is_finite(), + "spring_value(t={t}) with stiffness=0 must be finite, got {v}" + ); + } + } + + #[test] + fn negative_damping_stays_bounded_instead_of_diverging() { + let config = SpringConfig { + damping: -20.0, + stiffness: 100.0, + mass: 1.0, + }; + let v_at_5s = spring_value(5.0, &config); + assert!( + v_at_5s.is_finite() && v_at_5s.abs() < 100.0, + "spring_value(t=5.0) with damping=-20 must stay bounded (finite and reasonably \ + small), got {v_at_5s} — negative damping must not diverge to +-infinity" + ); + } +} diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 3b129e0..f9d454e 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -220,9 +220,24 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u viewport_width: ctx.viewport_size.0, viewport_height: ctx.viewport_size.1, parent_size: box_layout.width.max(box_layout.height), - font_size: 16.0, + font_size: node.css.font_size_px_or(16.0), root_font_size: 16.0, }; + // Per-axis contexts for `transform`'s translate percentages: CSS + // resolves a `translate`/`translate3d` x-component percentage against + // the box's own WIDTH and the y-component against its own HEIGHT — never + // `max(width, height)` on both axes (that's only correct for square + // boxes). Mirrors what `resolve_origin` already does for + // `transform-origin` below. `z`/`perspective()` keep the general + // (shared) context — CSS has no per-axis convention for them. + let length_ctx_x = LengthContext { + parent_size: box_layout.width, + ..length_ctx + }; + let length_ctx_y = LengthContext { + parent_size: box_layout.height, + ..length_ctx + }; canvas.save(); @@ -259,13 +274,18 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .perspective .as_ref() .map(|l| l.resolve(&length_ctx).max(1.0)); + let axes = TransformAxes { + x: length_ctx_x, + y: length_ctx_y, + general: length_ctx, + }; apply_transform( canvas, transform_list, perspective_d, transform_pivot, perspective_pivot, - &length_ctx, + &axes, ); } @@ -293,8 +313,50 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u }); } - // 3. opacity / filter layer — one shared layer carries both the group - // alpha and the CSS `filter` chain (applies to the node and its subtree). + // 3. backdrop-filter: filter what is *already painted behind this node* + // (earlier siblings, ancestor backgrounds), clipped to its own (rounded) + // border-box — the glassmorphism pattern. This must run BEFORE this + // node's own opacity/filter layer (step 4) opens: if it ran after (as it + // used to), the backdrop's `SaveLayerRec::backdrop()` would sample the + // freshly-opened, still-empty opacity layer instead of the real scene + // beneath it, making the blur a total no-op the instant `opacity < 1.0` + // or a `filter` is also present on the same node — exactly the + // glassmorphism + fade_in combination rules/glassmorphism.md recommends. + // Self-contained bracket (save/clip/layer/restore/restore): the panel is + // baked directly onto the canvas below, so this node's own opacity later + // fades its own background/border/content on top of it without + // re-fading the panel itself (avoiding a second, unrelated ordering + // hazard: a shared clip+layer would also have to stay open across + // background/border painting, reintroducing the overflow/shadow bug + // fixed below for those steps too). + if let Some(filters) = node.css.backdrop_filter.as_deref() { + if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) { + let radius = node + .css + .border_radius + .as_ref() + .map(|r| resolve_border_radius(r, box_layout, &length_ctx)) + .unwrap_or([0.0; 4]); + canvas.save(); + canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true); + let rec = SaveLayerRec::default().backdrop(&backdrop); + canvas.save_layer(&rec); + canvas.restore(); + canvas.restore(); + } + } + + // 4. opacity / filter layer — one shared layer carries both the group + // alpha and the CSS `filter` chain (applies to the node and its + // subtree). Bounded to the node's own box (padded by the filter chain's + // blur/drop-shadow bleed so those still bleed past the edge, unclipped): + // an unbounded `SaveLayerRec` sizes the layer against the current clip — + // usually the whole viewport — so every faded/filtered node allocates + // and composites a full-frame layer regardless of how small it is + // (measured on this repo's release binary, 1080x1920/60 frames, 30 small + // `opacity: 0.5` shapes, `--threads 1`: ~42-60s wall time unbounded vs. + // ~0.5s bounded — roughly two orders of magnitude, not a rounding + // error; cost scales with viewport area, not node size). let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0); let content_filter = node .css @@ -309,30 +371,33 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u if let Some(filter) = content_filter { paint.set_image_filter(filter); } - let rec = SaveLayerRec::default().paint(&paint); + let bleed = node + .css + .filter + .as_deref() + .map(|list| filter_bleed(list, &length_ctx)) + .unwrap_or(0.0); + let bounds = Rect::from_xywh( + box_layout.x - bleed, + box_layout.y - bleed, + box_layout.width + bleed * 2.0, + box_layout.height + bleed * 2.0, + ); + let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds); canvas.save_layer(&rec); true } else { false }; - // 4. clip overflow:hidden / clip - let overflow = node.css.overflow.unwrap_or(Overflow::Visible); - if matches!( - overflow, - Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto - ) { - let radius = node - .css - .border_radius - .as_ref() - .map(|r| resolve_border_radius(r, box_layout, &length_ctx)) - .unwrap_or([0.0; 4]); - let rrect = padding_rrect(box_layout, radius); - canvas.clip_rrect(rrect, ClipOp::Intersect, true); - } - - // 5. outset box-shadow + // 5. outset box-shadow, 6. background, 7. border — the box's own + // decorations. Painted BEFORE any overflow clip (step 8): CSS `overflow` + // clips a box's *descendants*, never the box's own border-box + // decorations (an outset box-shadow exists precisely outside the + // border-box; background/border are already shaped by border-radius on + // their own and gain nothing from an extra clip). They still sit inside + // the opacity/filter layer above so a faded node fades its whole + // appearance uniformly, background included. if let Some(shadows) = node.css.box_shadow.as_ref() { for shadow in shadows { if shadow.inset.unwrap_or(false) { @@ -341,41 +406,40 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false); } } - - // 5.5 backdrop-filter: filter what is already painted behind this node, - // clipped to its (rounded) border-box, before its own background goes on - // top — the glassmorphism pattern. - if let Some(filters) = node.css.backdrop_filter.as_deref() { - if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) { - let radius = node - .css - .border_radius - .as_ref() - .map(|r| resolve_border_radius(r, box_layout, &length_ctx)) - .unwrap_or([0.0; 4]); - canvas.save(); - canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true); - let rec = SaveLayerRec::default().backdrop(&backdrop); - canvas.save_layer(&rec); - canvas.restore(); - canvas.restore(); - } - } - - // 6. background if let Some(bg) = node.css.background.as_ref() { paint_background(canvas, box_layout, &node.css, bg, &length_ctx); } - - // 7. border — `gradient-border` replaces the standard border when present - // (a box has one border, not two stacked ones). + // `gradient-border` replaces the standard border when present (a box + // has one border, not two stacked ones). if let Some(gb) = node.css.gradient_border.as_ref() { paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx); } else if let Some(border) = node.css.border.as_ref() { paint_border(canvas, box_layout, &node.css, border, &length_ctx); } - // 8. component-specific content (Ghost is painted identically to Component; + // 8. clip overflow:hidden / clip — scoped to this node's own content and + // its children only (see step 5-7's comment for why the box's own + // decorations must stay outside this clip). + let overflow = node.css.overflow.unwrap_or(Overflow::Visible); + let opened_overflow_clip = if matches!( + overflow, + Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto + ) { + let radius = node + .css + .border_radius + .as_ref() + .map(|r| resolve_border_radius(r, box_layout, &length_ctx)) + .unwrap_or([0.0; 4]); + let rrect = padding_rrect(box_layout, radius); + canvas.save(); + canvas.clip_rrect(rrect, ClipOp::Intersect, true); + true + } else { + false + }; + + // 9. component-specific content (Ghost is painted identically to Component; // the only difference is that Ghost is excluded from the hit-map above). let payload_opt = match &node.kind { BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p), @@ -386,13 +450,17 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame); } - // 9. children (z-index ordered, then source order) + // 10. children (z-index ordered, then source order) let mut indices: Vec = (0..node.children.len()).collect(); indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0)); for &i in &indices { paint_node(canvas, &node.children[i], ctx, tree_depth + 1); } + if opened_overflow_clip { + canvas.restore(); + } + // inset shadows (after children so they overlay content) if let Some(shadows) = node.css.box_shadow.as_ref() { for shadow in shadows { @@ -408,6 +476,40 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u canvas.restore(); } +/// Conservative outward bleed (px) a `filter` chain can paint beyond the +/// node's own box — used to size the opacity/filter layer's `SaveLayerRec` +/// bounds generously enough that `blur`/`drop-shadow` never get clipped at +/// the box edge (see the perf fix in step 4 above: an unbounded layer costs +/// ~5.9x render time, but a *too-tight* one would silently clip filter +/// bleed, trading a perf bug for a correctness one). `1.5x` the nominal +/// radius covers the visible falloff of `image_filters::blur`'s Gaussian +/// (sigma = radius/2, and ~3*sigma is the point the kernel is visually +/// negligible). +fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 { + use crate::css::style::FilterFn; + let mut bleed = 0.0f32; + for f in list { + let b = match f { + FilterFn::Blur { radius } => radius.resolve(ctx).max(0.0) * 1.5, + FilterFn::DropShadow { + offset_x, + offset_y, + blur, + .. + } => { + let blur_bleed = blur + .as_ref() + .map(|b| b.resolve(ctx).max(0.0) * 1.5) + .unwrap_or(0.0); + offset_x.resolve(ctx).abs().max(offset_y.resolve(ctx).abs()) + blur_bleed + } + _ => 0.0, + }; + bleed = bleed.max(b); + } + bleed +} + // ---- CSS filters ---- /// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter` @@ -684,6 +786,19 @@ fn has_3d_transform(list: &[TransformFn]) -> bool { }) } +/// Per-axis length-resolution contexts for `transform`. CSS resolves a +/// `translate`/`translate3d` percentage's x-component against the box's own +/// width and its y-component against its own height — never `max(width, +/// height)` on both axes (see `apply_transform`/`transform_to_m44`). +/// `z`/`perspective()` have no established per-axis CSS convention, so they +/// keep the general (pre-existing) context. +#[derive(Clone, Copy)] +struct TransformAxes { + x: LengthContext, + y: LengthContext, + general: LengthContext, +} + /// Apply CSS transform + perspective to the canvas. /// /// # Parameters @@ -700,7 +815,7 @@ fn apply_transform( perspective_d: Option, transform_pivot: (f32, f32), perspective_pivot: (f32, f32), - ctx: &LengthContext, + axes: &TransformAxes, ) { // Detect whether perspective and transform pivots differ. let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001 @@ -713,16 +828,16 @@ fn apply_transform( for tr in list { match tr { TransformFn::Translate { x, y } => { - canvas.translate(Point::new(x.resolve(ctx), y.resolve(ctx))); + canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y))); } TransformFn::TranslateX { x } => { - canvas.translate(Point::new(x.resolve(ctx), 0.0)); + canvas.translate(Point::new(x.resolve(&axes.x), 0.0)); } TransformFn::TranslateY { y } => { - canvas.translate(Point::new(0.0, y.resolve(ctx))); + canvas.translate(Point::new(0.0, y.resolve(&axes.y))); } TransformFn::Translate3d { x, y, .. } => { - canvas.translate(Point::new(x.resolve(ctx), y.resolve(ctx))); + canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y))); } TransformFn::Scale { x, y } => { canvas.scale((*x, *y)); @@ -765,7 +880,7 @@ fn apply_transform( m.pre_concat(&css_perspective_m44(d)); } for tr in list { - m.pre_concat(&transform_to_m44(tr, ctx)); + m.pre_concat(&transform_to_m44(tr, axes)); } m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0)); canvas.concat_44(&m); @@ -789,7 +904,7 @@ fn apply_transform( // Inner transform bracket (transform-origin). m.pre_concat(&M44::translate(tp.0, tp.1, 0.0)); for tr in list { - m.pre_concat(&transform_to_m44(tr, ctx)); + m.pre_concat(&transform_to_m44(tr, axes)); } m.pre_concat(&M44::translate(-tp.0, -tp.1, 0.0)); @@ -820,15 +935,19 @@ fn css_perspective_m44(d: f32) -> M44 { ]) } -fn transform_to_m44(tr: &TransformFn, ctx: &LengthContext) -> M44 { +fn transform_to_m44(tr: &TransformFn, axes: &TransformAxes) -> M44 { match tr { - TransformFn::Translate { x, y } => M44::translate(x.resolve(ctx), y.resolve(ctx), 0.0), - TransformFn::TranslateX { x } => M44::translate(x.resolve(ctx), 0.0, 0.0), - TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(ctx), 0.0), - TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(ctx)), - TransformFn::Translate3d { x, y, z } => { - M44::translate(x.resolve(ctx), y.resolve(ctx), z.resolve(ctx)) + TransformFn::Translate { x, y } => { + M44::translate(x.resolve(&axes.x), y.resolve(&axes.y), 0.0) } + TransformFn::TranslateX { x } => M44::translate(x.resolve(&axes.x), 0.0, 0.0), + TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(&axes.y), 0.0), + TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(&axes.general)), + TransformFn::Translate3d { x, y, z } => M44::translate( + x.resolve(&axes.x), + y.resolve(&axes.y), + z.resolve(&axes.general), + ), TransformFn::Scale { x, y } => M44::scale(*x, *y, 1.0), TransformFn::ScaleX { x } => M44::scale(*x, 1.0, 1.0), TransformFn::ScaleY { y } => M44::scale(1.0, *y, 1.0), @@ -896,7 +1015,9 @@ fn transform_to_m44(tr: &TransformFn, ctx: &LengthContext) -> M44 { 0.0, 1.0, ]), - TransformFn::Perspective { length } => css_perspective_m44(length.resolve(ctx).max(1.0)), + TransformFn::Perspective { length } => { + css_perspective_m44(length.resolve(&axes.general).max(1.0)) + } TransformFn::Matrix { values: v } => M44::row_major(&[ v[0], v[2], 0.0, v[4], v[1], v[3], 0.0, v[5], 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ]), @@ -1519,6 +1640,113 @@ mod hit_tests { ); } + #[test] + fn backdrop_filter_survives_sibling_opacity_below_one() { + // Same scene as `backdrop_filter_blurs_content_behind`, but the panel + // also carries `opacity: 0.99` — a visually-imperceptible change that + // must NOT disable the blur. Bug: the opacity/filter SaveLayerRec + // (paint_pass step 3) used to open BEFORE the backdrop-filter's own + // save_layer(backdrop) (old step 5.5), so the backdrop sampled the + // freshly-opened, still-empty opacity layer instead of the real + // scene beneath it — a total no-op. `opacity` alone (no + // `backdrop_filter`) is not the trigger; only nodes that combine + // both are affected, which is exactly the documented glassmorphism + // template (glassmorphism.md pairs `backdrop-filter` with a + // `fade_in`/`fade_in_up` entrance animation that drives `opacity`). + use crate::css::style::{Background, Color as CssColor, FilterFn}; + use crate::css::units::Length; + + let black_top = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(0.0)), + top: Some(CLP::Px(0.0)), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + background: Some(Background::Color(CssColor::String("#000000".into()))), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let panel = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(50.0)), + top: Some(CLP::Px(50.0)), + width: Some(CSize::Length(CLP::Px(100.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + backdrop_filter: Some(vec![FilterFn::Blur { + radius: Length::Px(10.0), + }]), + opacity: Some(0.99), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(200.0))), + background: Some(Background::Color(CssColor::String("#ffffff".into()))), + ..Default::default() + }, + children: vec![black_top, panel], + intrinsic: None, + source_path: None, + window: None, + }; + root.assign_ids(0); + + let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap(); + paint_tree( + surface.canvas(), + &root, + &layout, + &test_frame(200, 200), + &NoopDispatcher, + ); + + let info = skia_safe::ImageInfo::new( + (200, 200), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 200 * 200 * 4]; + assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0))); + let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32; + + // Outside the panel: hard edge preserved. + assert!(red(10, 97) < 10, "outside/above must stay black"); + assert!(red(10, 103) > 245, "outside/below must stay white"); + // Inside the panel: boundary must still smear into greys — the bug + // makes this a hard 0/255 edge identical to the outside columns. + let above = red(100, 97); + let below = red(100, 103); + assert!( + above > 30, + "backdrop not blurred above boundary with opacity:0.99 (r={above})" + ); + assert!( + below < 225, + "backdrop not blurred below boundary with opacity:0.99 (r={below})" + ); + } + #[test] fn hitmap_reflects_node_transform() { use crate::css::style::TransformFn; @@ -1970,6 +2198,54 @@ mod transform_origin_tests { "expected some red pixels with distinct origins" ); } + + // ---- Test 8: translate percentages resolve per-axis, not max(w,h) ---- + + #[test] + fn translate_percent_resolves_against_own_axis_not_max_dimension() { + // A 200x100 red box at (0,0), `transform: translate(50%, 50%)`. + // CSS resolves a translate-x percentage against the box's own WIDTH + // and translate-y against its own HEIGHT — never `max(width, + // height)` on both axes (only correct for square boxes). Expected: + // x shifts by 100 (50% of 200) -> [100,299]; y shifts by 50 (50% of + // 100) -> [50,149]. The bug instead resolved y against max(200,100) + // = 200, doubling the vertical shift to +100 -> [100,199]. + let mut n = red_box(Position::Absolute, 0.0, 0.0, 200.0, 100.0); + n.css.transform = Some(vec![TransformFn::Translate { + x: CLP::String("50%".into()), + y: CLP::String("50%".into()), + }]); + let mut root = root_node(400.0, 400.0, vec![n]); + let buf = render_pixels(&mut root, 400, 400); + + let mut min_x = u32::MAX; + let mut max_x = 0u32; + let mut min_y = u32::MAX; + let mut max_y = 0u32; + for y in 0..400u32 { + for x in 0..400u32 { + let i = ((y * 400 + x) * 4) as usize; + if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 { + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + assert_ne!(min_x, u32::MAX, "expected some red pixels"); + + assert!((min_x as i32 - 100).abs() <= 2, "min_x={min_x}"); + assert!((max_x as i32 - 299).abs() <= 2, "max_x={max_x}"); + assert!( + (min_y as i32 - 50).abs() <= 2, + "min_y={min_y} (expected ~50; the max(w,h) bug would give ~100)" + ); + assert!( + (max_y as i32 - 149).abs() <= 2, + "max_y={max_y} (expected ~149; the max(w,h) bug would give ~199)" + ); + } } #[cfg(test)] @@ -2395,6 +2671,229 @@ mod glassmorphism_tests { } } +#[cfg(test)] +mod paint_order_tests { + //! TDD tests for two paint-pass audit findings: + //! - overflow:hidden must never clip a node's OWN outset box-shadow + //! (CSS clips descendants, never the box's own decorations). + //! - the opacity/filter SaveLayerRec must be bounded to the node's box + //! (+ filter bleed), not left to size against the ambient clip + //! (usually the whole viewport) — without clipping visible blur. + + use super::*; + + use crate::css::style::{ + Background, BoxShadow, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection, + Overflow, Position, Size as CSize, + }; + use crate::css::taffy_bridge::ConversionContext; + use crate::css::units::{Length, LengthPercentage as CLP}; + use crate::engine::box_tree::{BoxKind, BoxNode}; + use crate::engine::layout_pass::run_layout; + + fn test_frame(w: u32, h: u32) -> PaintFrame { + PaintFrame { + time: 0.0, + frame_index: 0, + fps: 30, + video_width: w, + video_height: h, + scene_duration: 1.0, + camera: None, + } + } + + fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + paint_tree( + surface.canvas(), + root, + &layout, + &test_frame(w, h), + &NoopDispatcher, + ); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0)); + buf + } + + fn root_node(w: f32, h: f32, background: &str, children: Vec) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: Some(Background::Color(CssColor::String(background.to_string()))), + ..Default::default() + }, + children, + intrinsic: None, + source_path: None, + window: None, + } + } + + /// Count of "probe" red pixels (spread-only, blur:0, so a hard-edged + /// halo) in a rectangular region — used to compare before/after pixel + /// counts for the paint-order fix. + fn count_red_in(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize { + let mut n = 0; + for y in y0..y1 { + for x in x0..x1 { + let i = ((y * w + x) * 4) as usize; + if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 { + n += 1; + } + } + } + n + } + + fn card_with_shadow(overflow_hidden: bool) -> BoxNode { + let mut css = CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(50.0)), + top: Some(CLP::Px(50.0)), + width: Some(CSize::Length(CLP::Px(100.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + background: Some(Background::Color(CssColor::String("#ffffff".into()))), + box_shadow: Some(vec![BoxShadow { + offset_x: Length::Px(0.0), + offset_y: Length::Px(0.0), + blur: None, + spread: Some(Length::Px(20.0)), + color: Some(CssColor::String("#ff0000".into())), + inset: None, + }]), + ..Default::default() + }; + if overflow_hidden { + css.overflow = Some(Overflow::Hidden); + } + BoxNode { + id: 0, + kind: BoxKind::Container, + css, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + #[test] + fn overflow_hidden_does_not_clip_own_outset_box_shadow() { + // 100x100 white card at (50,50) on a 200x200 black canvas, outset + // box-shadow (red, spread 20, blur 0 -> hard-edged halo rect from + // (30,30) to (170,170)). Probe points (100,45) and (100,155) sit in + // the halo band above/below the card, outside its own border-box. + let without = { + let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(false)]); + render_pixels(&mut root, 200, 200) + }; + let with_hidden = { + let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(true)]); + render_pixels(&mut root, 200, 200) + }; + + let probe = |buf: &[u8], x: usize, y: usize| -> (u8, u8, u8) { + let i = (y * 200 + x) * 4; + (buf[i], buf[i + 1], buf[i + 2]) + }; + + let above_plain = probe(&without, 100, 45); + let below_plain = probe(&without, 100, 155); + assert!( + above_plain.0 > 200 && above_plain.1 < 50, + "sanity: shadow halo must be visible without overflow, got {above_plain:?}" + ); + assert!( + below_plain.0 > 200 && below_plain.1 < 50, + "sanity: shadow halo must be visible without overflow, got {below_plain:?}" + ); + + let above_hidden = probe(&with_hidden, 100, 45); + let below_hidden = probe(&with_hidden, 100, 155); + assert!( + above_hidden.0 > 200 && above_hidden.1 < 50, + "overflow:hidden must not erase the node's own outset shadow, got {above_hidden:?}" + ); + assert!( + below_hidden.0 > 200 && below_hidden.1 < 50, + "overflow:hidden must not erase the node's own outset shadow, got {below_hidden:?}" + ); + + // Probe-pixel count over the full halo band, before/after overflow. + let halo_count_plain = count_red_in(&without, 200, 25, 25, 175, 175); + let halo_count_hidden = count_red_in(&with_hidden, 200, 25, 25, 175, 175); + assert_eq!( + halo_count_plain, halo_count_hidden, + "halo pixel count must be identical with/without overflow:hidden \ + (plain={halo_count_plain}, hidden={halo_count_hidden})" + ); + } + + #[test] + fn filter_layer_bounds_do_not_clip_blur_bleed() { + // A 60x60 opaque red square with `opacity: 0.999` (forces the + // SaveLayerRec open) AND `filter: blur(24px)` on a 300x300 black + // canvas. Bounding the layer to the node's box (issue #4 fix) must + // still leave room for the blur to bleed outward — if the bounds + // were the bare box rect, Skia would hard-clip the blurred fringe + // at the box edge, and the region just outside the box would stay + // pure black instead of picking up a soft red glow. + let n = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(120.0)), + top: Some(CLP::Px(120.0)), + width: Some(CSize::Length(CLP::Px(60.0))), + height: Some(CSize::Length(CLP::Px(60.0))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + opacity: Some(0.999), + filter: Some(vec![FilterFn::Blur { + radius: Length::Px(24.0), + }]), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = root_node(300.0, 300.0, "#000000", vec![n]); + let buf = render_pixels(&mut root, 300, 300); + + let probe = |x: usize, y: usize| -> u8 { + let i = (y * 300 + x) * 4; + buf[i] + }; + // 8px outside the left edge of the box (box left edge = x=120), + // vertically centered (y=150): must show blur bleed (red > black). + let bled = probe(112, 150); + assert!( + bled > 15, + "blur must bleed past the box edge under bounded SaveLayerRec, got r={bled}" + ); + // Far outside any plausible bleed radius: must stay black. + let far = probe(20, 20); + assert_eq!(far, 0, "far corner must stay untouched, got r={far}"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs index 9388ab3..879d86c 100644 --- a/crates/rustmotion-core/src/engine/renderer/fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs @@ -13,57 +13,131 @@ use super::google_fonts::{font_cache_dir, resolve_google_font}; thread_local! { static THREAD_FONT_MGR: FontMgr = FontMgr::default(); // Per-thread cache of Typefaces built from the global custom-font bytes, - // so each render thread builds each custom face at most once. - static CUSTOM_TYPEFACES: RefCell> = RefCell::new(HashMap::new()); + // keyed by (family, weight, italic) — the exact variant `custom_typeface` + // picked — so each render thread builds each custom face at most once. + static CUSTOM_TYPEFACES: RefCell> = + RefCell::new(HashMap::new()); } pub fn font_mgr() -> FontMgr { THREAD_FONT_MGR.with(|mgr| mgr.clone()) } +/// One registered custom-font file: its raw bytes plus the `(weight, +/// italic)` style Skia parsed out of the file itself when it was +/// registered — the ground truth for what that file actually renders as, +/// independent of which nominal weight the caller happened to request it +/// under. +#[derive(Clone)] +struct CustomFontVariant { + data: Vec, + weight: i32, + italic: bool, +} + /// Global registry of custom/Google-font bytes, keyed by family name. Filled /// once by [`load_custom_fonts`] on the main thread; read by every render -/// thread through [`custom_typeface`]. A family maps to the first file -/// registered for it (one weight per custom family via this path — sufficient -/// for accent display faces; multi-weight custom families are future work). -fn custom_font_registry() -> &'static Mutex>> { - static REG: OnceLock>>> = OnceLock::new(); +/// thread through [`custom_typeface`]. Each family holds every distinct +/// `(weight, italic)` variant registered for it — e.g. a Google Fonts +/// declaration with `weights: [400, 700]` registers two variants — so +/// [`custom_typeface`] can pick whichever is the closest match to what a +/// paint call asks for, instead of always returning the first file that +/// happened to register (the previous behaviour: every variant after the +/// first was invisible, and every weight/style request resolved to +/// whichever one file won the race). +fn custom_font_registry() -> &'static Mutex>> { + static REG: OnceLock>>> = OnceLock::new(); REG.get_or_init(|| Mutex::new(HashMap::new())) } -/// Store a custom font's bytes under `family` (first registration wins). -pub fn register_custom_font_bytes(family: &str, data: Vec) { +/// Register a custom font's bytes under `family`, tagged with the `(weight, +/// italic)` style Skia reports for the parsed file. A no-op if that exact +/// `(family, weight, italic)` combination is already registered. +pub fn register_custom_font_variant(family: &str, data: Vec, weight: i32, italic: bool) { let mut reg = custom_font_registry() .lock() .unwrap_or_else(|e| e.into_inner()); - reg.entry(family.to_string()).or_insert(data); + let variants = reg.entry(family.to_string()).or_default(); + if !variants + .iter() + .any(|v| v.weight == weight && v.italic == italic) + { + variants.push(CustomFontVariant { + data, + weight, + italic, + }); + } } -/// The raw bytes registered for `family`, if any (test/introspection helper). -pub fn custom_font_bytes(family: &str) -> Option> { - custom_font_registry() +/// The raw bytes registered for `family`'s closest `(weight, italic)` match, +/// if any variant is registered under that family (test/introspection +/// helper). +#[cfg(test)] +fn custom_font_bytes(family: &str, weight: i32, italic: bool) -> Option> { + let reg = custom_font_registry() .lock() - .unwrap_or_else(|e| e.into_inner()) - .get(family) - .cloned() + .unwrap_or_else(|e| e.into_inner()); + let variants = reg.get(family)?; + closest_variant(variants, weight, italic).map(|v| v.data.clone()) +} + +/// Pick the registered variant closest to `(weight, italic)`: exact +/// italic-ness match preferred, then the smallest weight distance — the +/// same nearest-match spirit as CSS font matching (`font-weight`/ +/// `font-style` never fail to resolve to *something*, they resolve to the +/// closest available face). +fn closest_variant( + variants: &[CustomFontVariant], + weight: i32, + italic: bool, +) -> Option<&CustomFontVariant> { + variants.iter().min_by_key(|v| { + let italic_penalty = if v.italic == italic { 0 } else { 1_000_000 }; + italic_penalty + (v.weight - weight).abs() + }) } -/// Resolve a registered custom font to a Typeface, building it from the global -/// bytes on first use per thread and caching it thereafter. `None` when no -/// custom font is registered under `family`. -fn custom_typeface(family: &str) -> Option { +/// Resolve a registered custom font to a Typeface for the requested `style`, +/// building it from the global bytes on first use per thread and caching it +/// thereafter. `None` when no custom font is registered under `family`. +fn custom_typeface(family: &str, style: FontStyle) -> Option { + let weight = *style.weight(); + let italic = style.slant() != skia_safe::font_style::Slant::Upright; + let cache_key = (family.to_string(), weight, italic); CUSTOM_TYPEFACES.with(|cache| { - if let Some(tf) = cache.borrow().get(family) { + if let Some(tf) = cache.borrow().get(&cache_key) { return Some(tf.clone()); } - let data = custom_font_bytes(family)?; + let data = { + let reg = custom_font_registry() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let variants = reg.get(family)?; + closest_variant(variants, weight, italic)?.data.clone() + }; let sk_data = skia_safe::Data::new_copy(&data); let tf = font_mgr().new_from_data(&sk_data, None)?; - cache.borrow_mut().insert(family.to_string(), tf.clone()); + cache.borrow_mut().insert(cache_key, tf.clone()); Some(tf) }) } +/// Look up only the custom/Google-font registry for `family` at the +/// requested `style`, without falling through to any system font. Exposed +/// for callers (e.g. `codeblock`/`terminal`'s monospace font resolver) that +/// need to check "did the scenario declare a custom font for this family" +/// *before* trying their own family-specific system fallback chain — unlike +/// [`typeface_with_fallback`], which interleaves a single system-family +/// lookup between the custom check and its own generic Helvetica/Arial +/// catch-all, an order that doesn't suit every caller (see issue: codeblock/ +/// terminal's hardcoded monospace fallback list was never reached because +/// `typeface_with_fallback`'s own system lookup already matched a decoy +/// system family, e.g. "JetBrains Mono"). +pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option { + custom_typeface(family, style) +} + /// Validate a `FontEntry` and resolve it to a list of TTF file paths. /// /// - Local entry (`path` set, `source` absent): returns `[path]` as-is. @@ -132,20 +206,28 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) match std::fs::read(path) { Ok(data) => { let sk_data = skia_safe::Data::new_copy(&data); - if font_mgr.new_from_data(&sk_data, None).is_none() { + let Some(tf) = font_mgr.new_from_data(&sk_data, None) else { eprintln!( "Warning: failed to register custom font '{}' from '{}'", family, path.display() ); return; - } + }; // Skia's default FontMgr can build a Typeface from `new_from_data` // but never exposes it to `match_family_style` (name lookup only // sees installed system fonts). So keep the raw bytes in a global // registry; `typeface_with_fallback` builds and caches a Typeface - // from them per thread, ahead of the system match. - register_custom_font_bytes(family, data); + // from them per thread, ahead of the system match. Tag the + // variant with the (weight, italic) Skia parsed out of the file + // itself — the ground truth for what it actually renders as — + // so a family with several registered weights (e.g. Google + // Fonts `weights: [400, 700]`) exposes every one of them instead + // of only whichever file happened to register first. + let parsed_style = tf.font_style(); + let weight = *parsed_style.weight(); + let italic = parsed_style.slant() != skia_safe::font_style::Slant::Upright; + register_custom_font_variant(family, data, weight, italic); } Err(e) => { eprintln!( @@ -166,8 +248,10 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result { // Custom/Google fonts declared in the scenario win over system fonts: // they are not visible to `match_family_style`, so resolve them from the - // registry first. - if let Some(t) = custom_typeface(family) { + // registry first — matched against the requested `style` so a family + // registered with several weights picks the right one instead of + // whichever file happened to register first (#6). + if let Some(t) = custom_typeface(family, style) { return Ok(t); } let fm = font_mgr(); @@ -200,6 +284,42 @@ pub fn emoji_typeface() -> Option { EMOJI_TF.with(|tf| tf.clone()) } +/// Resolve a system fallback typeface that actually contains a glyph for +/// `c`, for when `primary_family`'s own face doesn't cover it (audit #3: +/// CJK/Arabic/Devanagari/other scripts rendered as `.notdef` tofu when only +/// a Latin `font-family` was requested, because neither measurement nor +/// painting ever looked past the single requested typeface). This is +/// Skia's font-fallback-by-character API — the same mechanism a browser +/// uses to substitute, say, a CJK font for Chinese text embedded in an +/// otherwise-Latin paragraph, instead of leaving `.notdef` tofu. Memoized +/// per thread (keyed on the inputs that actually affect the OS's fallback +/// decision) since callers may probe this once per uncovered code point +/// during run segmentation. Returns `None` if no installed font covers `c` +/// either — the caller falls back to the originally requested (tofu- +/// producing) font, exactly the pre-fix behaviour, not worse. +pub fn fallback_typeface_for_char( + primary_family: &str, + style: FontStyle, + c: char, +) -> Option { + thread_local! { + static FALLBACK_CACHE: RefCell>> = + RefCell::new(HashMap::new()); + } + let weight = *style.weight(); + let italic = style.slant() != skia_safe::font_style::Slant::Upright; + let key = (primary_family.to_string(), weight, italic, c as u32); + FALLBACK_CACHE.with(|cache| { + if let Some(hit) = cache.borrow().get(&key) { + return hit.clone(); + } + let resolved = + font_mgr().match_family_style_character(primary_family, style, &[], c as i32); + cache.borrow_mut().insert(key, resolved.clone()); + resolved + }) +} + // ─── Unit tests ────────────────────────────────────────────────────────────── #[cfg(test)] @@ -251,15 +371,60 @@ mod tests { } #[test] - fn custom_font_registry_stores_first_and_serves_bytes() { - register_custom_font_bytes("RmProbeRegistryFamily", vec![1, 2, 3]); - // First registration wins (a later weight must not clobber it). - register_custom_font_bytes("RmProbeRegistryFamily", vec![9, 9]); + fn custom_font_registry_stores_distinct_weights_and_serves_bytes() { + register_custom_font_variant("RmProbeRegistryFamily", vec![1, 2, 3], 400, false); + // A *different* (weight, italic) is a genuinely new variant — not a + // clobber of the first (the old `family`-only-keyed `or_insert` + // registry made every registration after the first invisible; #6). + register_custom_font_variant("RmProbeRegistryFamily", vec![9, 9], 700, false); assert_eq!( - custom_font_bytes("RmProbeRegistryFamily"), + custom_font_bytes("RmProbeRegistryFamily", 400, false), Some(vec![1, 2, 3]) ); - assert!(custom_font_bytes("RmProbeUnregistered").is_none()); + assert_eq!( + custom_font_bytes("RmProbeRegistryFamily", 700, false), + Some(vec![9, 9]) + ); + assert!(custom_font_bytes("RmProbeUnregistered", 400, false).is_none()); + } + + #[test] + fn registering_the_same_weight_twice_keeps_the_first() { + register_custom_font_variant("RmProbeDupeFamily", vec![1, 2, 3], 400, false); + register_custom_font_variant("RmProbeDupeFamily", vec![9, 9], 400, false); + assert_eq!( + custom_font_bytes("RmProbeDupeFamily", 400, false), + Some(vec![1, 2, 3]), + "re-registering the same (weight, italic) must not clobber the first file" + ); + } + + #[test] + fn custom_typeface_lookup_picks_the_closest_registered_weight() { + // Pure selection-logic reproduction of #6's fix mechanism, + // independent of any font actually installed on the host: three + // variants registered under one family; a lookup for an + // intermediate weight must pick the *closest* one, not always the + // first registered — the defect the audit measured (bold and + // normal always resolving to the same file). + register_custom_font_variant("RmProbeClosestFamily", vec![1], 400, false); + register_custom_font_variant("RmProbeClosestFamily", vec![2], 700, false); + register_custom_font_variant("RmProbeClosestFamily", vec![3], 900, false); + + let reg = custom_font_registry() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let variants = reg.get("RmProbeClosestFamily").expect("registered above"); + assert_eq!( + closest_variant(variants, 650, false).unwrap().weight, + 700, + "650 should resolve to the nearest registered weight, 700" + ); + assert_eq!( + closest_variant(variants, 100, false).unwrap().weight, + 400, + "100 should resolve to the nearest registered weight, 400" + ); } /// The bug this fix targets: a registered custom family must resolve to the @@ -275,7 +440,17 @@ mod tests { let Ok(bytes) = std::fs::read(&path) else { return; // cold font cache → skip (render QA covers it) }; - register_custom_font_bytes("Anton", bytes); + let fm = font_mgr(); + let parsed = fm + .new_from_data(&skia_safe::Data::new_copy(&bytes), None) + .expect("cached TTF must parse"); + let style = parsed.font_style(); + register_custom_font_variant( + "Anton", + bytes, + *style.weight(), + style.slant() != skia_safe::font_style::Slant::Upright, + ); let tf = typeface_with_fallback("Anton", FontStyle::normal()).unwrap(); assert_eq!( tf.family_name(), @@ -284,6 +459,56 @@ mod tests { ); } + /// End-to-end reproduction of #6: a family registered with two distinct + /// weights (mirrors `fonts: [{"family":"Inter","source":"google", + /// "weights":[400,700]}]`) must resolve *different* typefaces for + /// `font-weight: normal` vs `font-weight: bold`. Before the fix, + /// `custom_typeface` ignored `style` entirely and `register_custom_ + /// font_bytes` kept only the first-registered file, so the audit's two + /// rendered PNGs (bold vs normal) came out byte-for-byte identical. + /// Skips on a cold font cache (no network access in CI) — the render QA + /// in `examples/` is the visual counterpart. + #[test] + fn family_with_two_registered_weights_resolves_distinct_typefaces() { + let cache_dir = format!( + "{}/.cache/rustmotion/fonts", + std::env::var("HOME").unwrap_or_default() + ); + let (Ok(normal_bytes), Ok(bold_bytes)) = ( + std::fs::read(format!("{cache_dir}/inter-400.ttf")), + std::fs::read(format!("{cache_dir}/inter-700.ttf")), + ) else { + return; // cold font cache → skip (render QA covers it) + }; + + let fm = font_mgr(); + let normal_parsed = fm + .new_from_data(&skia_safe::Data::new_copy(&normal_bytes), None) + .expect("cached TTF must parse"); + let bold_parsed = fm + .new_from_data(&skia_safe::Data::new_copy(&bold_bytes), None) + .expect("cached TTF must parse"); + let normal_weight = *normal_parsed.font_style().weight(); + let bold_weight = *bold_parsed.font_style().weight(); + + register_custom_font_variant("RmProbeInterFamily", normal_bytes, normal_weight, false); + register_custom_font_variant("RmProbeInterFamily", bold_bytes, bold_weight, false); + + let resolved_normal = + typeface_with_fallback("RmProbeInterFamily", FontStyle::normal()).unwrap(); + let resolved_bold = + typeface_with_fallback("RmProbeInterFamily", FontStyle::bold()).unwrap(); + + assert_ne!( + *resolved_normal.font_style().weight(), + *resolved_bold.font_style().weight(), + "requesting normal vs bold on the same custom family must resolve different weights \ + (both used to resolve to whichever file registered first)" + ); + assert_eq!(*resolved_bold.font_style().weight(), bold_weight); + assert_eq!(*resolved_normal.font_style().weight(), normal_weight); + } + #[test] fn neither_path_nor_source_is_error() { let entry = neither_entry(); diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs index 081be2b..d607dad 100644 --- a/crates/rustmotion-core/src/engine/renderer/text.rs +++ b/crates/rustmotion-core/src/engine/renderer/text.rs @@ -1,4 +1,4 @@ -use skia_safe::{Canvas, Font, Paint, Point, TextBlob}; +use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface}; // ─── Counter formatting ───────────────────────────────────────────────────── @@ -120,8 +120,14 @@ pub fn make_text_blob_with_spacing(text: &str, font: &Font, spacing: f32) -> Opt // ─── Emoji support ────────────────────────────────────────────────────────── -/// Check if a character is an emoji or emoji-related codepoint. -fn is_emoji(c: char) -> bool { +/// Code points that render as emoji **by default**, in every context, +/// regardless of any following variation selector — genuine pictograph +/// blocks (Miscellaneous Symbols and Pictographs, Emoticons, Transport, +/// Supplemental Symbols/Pictographs, flags, keycaps, ZWJ sequences...). +/// Nothing in these ranges has a meaningful plain-text rendering, so there +/// is no narrowing to do here — contrast with +/// [`is_text_presentation_by_default`], which needs one (audit #8). +fn is_emoji_presentation_default(c: char) -> bool { let cp = c as u32; matches!(cp, // Miscellaneous Symbols and Pictographs (includes skin tone modifiers 1F3FB-1F3FF) @@ -136,12 +142,6 @@ fn is_emoji(c: char) -> bool { 0x1FA00..=0x1FA6F | // Symbols and Pictographs Extended-B 0x1FA70..=0x1FAFF | - // Dingbats (includes ✂️..➰ and arrows/symbols) - 0x2702..=0x27B0 | - // Miscellaneous Symbols (includes ☀️..⛿) - 0x2600..=0x26FF | - // Variation Selectors (keep with preceding emoji) - 0xFE00..=0xFE0F | // Zero-Width Joiner 0x200D | // Combining Enclosing Keycap @@ -152,68 +152,247 @@ fn is_emoji(c: char) -> bool { 0xE0020..=0xE007F | // Playing cards, mahjong 0x1F004 | 0x1F0CF | - // Misc technical (⌚ ⌛ ⏩..⏳ ⏸..⏺) - 0x231A..=0x231B | + // Misc technical, unconditionally emoji (⏩..⏳ ⏸..⏺) 0x23E9..=0x23F3 | 0x23F8..=0x23FA | - // Arrows and geometric symbols used as emoji + // Arrows and geometric symbols, unconditionally emoji + 0x2B1B..=0x2B1C | + 0x2B50 | 0x2B55 + ) +} + +/// Code points that are TEXT-presentation **by default** — a normal glyph +/// in the primary font, honouring `style.color` — but that Unicode still +/// marks `Emoji=Yes`: they render as color emoji only when the author +/// explicitly opts in with a following U+FE0F variation selector (audit +/// #8). Routing these unconditionally to the emoji font (the previous +/// behaviour, lumped in with [`is_emoji_presentation_default`]) either +/// painted a color bitmap that ignores `style.color` (✔, ©, ®, ™ — all +/// covered by Apple Color Emoji) or a `.notdef` tofu square for code points +/// the emoji font itself doesn't cover even though the primary font does +/// (✓ U+2713 — absent from Apple Color Emoji even though U+2714 sits one +/// code point over and is present). +fn is_text_presentation_by_default(c: char) -> bool { + let cp = c as u32; + matches!(cp, + // Copyright, registered, trademark + 0x00A9 | 0x00AE | 0x2122 | + // Misc technical (⌚ ⌛) + 0x231A..=0x231B | + // Miscellaneous Symbols (☀️..⛿) + 0x2600..=0x26FF | + // Dingbats (✂️..➰ and arrows/symbols), includes ✓/✔ U+2713/2714 + 0x2702..=0x27B0 | + // Arrows and geometric symbols used as emoji only with VS16 0x2934..=0x2935 | 0x25AA..=0x25AB | 0x25B6 | 0x25C0 | 0x25FB..=0x25FE | - // Arrows 0x2B05..=0x2B07 | - 0x2B1B..=0x2B1C | - 0x2B50 | 0x2B55 | // CJK symbols 0x3030 | 0x303D | - 0x3297 | 0x3299 | - // Copyright, registered, trademark - 0x00A9 | 0x00AE | 0x2122 + 0x3297 | 0x3299 ) } -/// A segment of text that uses either the primary font or the emoji font. -struct TextRun { - start: usize, // byte offset - end: usize, // byte offset - is_emoji: bool, +/// Variation Selector-16 — forces the *preceding* text-presentation-default +/// code point (see [`is_text_presentation_by_default`]) into emoji +/// presentation. Narrower than the old catch-all `0xFE00..=0xFE0F` range: +/// VS-15 (U+FE0E, forces *text* presentation) and the rest of that block +/// are not emoji-forcing. +const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}'; + +/// True if `c` should be painted/measured with the emoji font, given the +/// character immediately following it (`next`). Needed because +/// [`is_text_presentation_by_default`] code points only opt into emoji +/// presentation when explicitly followed by U+FE0F — a bare lookup of `c` +/// alone can't tell "©" (text) from "©️" (explicit emoji presentation) +/// apart. +fn char_wants_emoji_font(c: char, next: Option) -> bool { + if c == VARIATION_SELECTOR_EMOJI { + return true; // always grouped with whatever code point selected it + } + if is_emoji_presentation_default(c) { + return true; + } + if is_text_presentation_by_default(c) { + return next == Some(VARIATION_SELECTOR_EMOJI); + } + false } -/// Segment text into runs of emoji vs non-emoji characters. -fn segment_text_runs(text: &str) -> Vec { - let mut runs = Vec::new(); - let mut chars = text.char_indices().peekable(); +/// Which font a run of text should be painted/measured with. +enum RunKind { + Primary, + Emoji, + /// #3: a system fallback typeface resolved for a code point the + /// primary font doesn't cover (e.g. CJK/Arabic/Devanagari when only a + /// Latin `font-family` was requested). + Fallback(Typeface), +} + +/// True if `a` and `b` are the "same" run kind for the purpose of merging +/// adjacent characters into one run. Two `Fallback` runs merge only if they +/// resolved to the *same* typeface (compared by Skia's unique id) — two +/// characters that both need fallback but belong to different scripts +/// (e.g. mixed CJK + Arabic) must not merge into a single run painted with +/// only one of the two fonts. +fn same_run_kind(a: &RunKind, b: &RunKind) -> bool { + match (a, b) { + (RunKind::Primary, RunKind::Primary) => true, + (RunKind::Emoji, RunKind::Emoji) => true, + (RunKind::Fallback(ta), RunKind::Fallback(tb)) => ta.unique_id() == tb.unique_id(), + _ => false, + } +} - while let Some(&(start_byte, c)) = chars.peek() { - let emoji = is_emoji(c); +/// True if `font` has an actual glyph (not `.notdef`, glyph id 0) for every +/// character in `text`. Used both to gate whether a code point needs a +/// fallback lookup at all (#3), and as a coverage guard before actually +/// using the emoji font for a run classified as emoji (#8): some code +/// points Unicode marks emoji-capable are, on a given platform, absent +/// from the color emoji font even though the primary font has a perfectly +/// good text glyph for them (U+2713 on Apple Color Emoji). +fn font_covers(font: &Font, text: &str) -> bool { + let glyphs = font.str_to_glyphs_vec(text); + !glyphs.is_empty() && glyphs.iter().all(|&g| g != 0) +} + +/// Classify a single character's font choice: emoji presentation first +/// (#8), then primary-font glyph coverage, then a system fallback typeface +/// resolved via [`super::fallback_typeface_for_char`] for the primary +/// font's own `(family, style)` (#3). Whitespace/control code points are +/// always `Primary` (assumed universally present, or invisible) so the +/// fallback lookup isn't triggered by the spaces between same-script words. +fn classify_char(c: char, primary: &Font, next: Option) -> RunKind { + if char_wants_emoji_font(c, next) { + return RunKind::Emoji; + } + if c.is_whitespace() || (c as u32) < 0x20 { + return RunKind::Primary; + } + if primary.unichar_to_glyph(c as i32) != 0 { + return RunKind::Primary; + } + let primary_typeface = primary.typeface(); + let style = primary_typeface.font_style(); + let family = primary_typeface.family_name(); + match super::fallback_typeface_for_char(&family, style, c) { + Some(tf) => RunKind::Fallback(tf), + // No installed font covers `c` either — same `.notdef` tofu as + // before this fix, not worse. + None => RunKind::Primary, + } +} + +/// Segment text into runs, each tagged with which font should paint/measure +/// it (see [`classify_char`]). +fn segment_text_runs(text: &str, primary: &Font) -> Vec { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let mut runs = Vec::new(); + let mut i = 0; + while i < chars.len() { + let (start_byte, c) = chars[i]; + let next = chars.get(i + 1).map(|&(_, ch)| ch); + let kind = classify_char(c, primary, next); let mut end_byte = start_byte + c.len_utf8(); - chars.next(); + i += 1; - while let Some(&(_, next_c)) = chars.peek() { - if is_emoji(next_c) != emoji { + while let Some(&(nb, nc)) = chars.get(i) { + let nnext = chars.get(i + 1).map(|&(_, ch)| ch); + let nkind = classify_char(nc, primary, nnext); + if !same_run_kind(&kind, &nkind) { break; } - end_byte += next_c.len_utf8(); - chars.next(); + end_byte = nb + nc.len_utf8(); + i += 1; } runs.push(TextRun { start: start_byte, end: end_byte, - is_emoji: emoji, + kind, }); } runs } -/// Check if text contains any emoji characters. +/// A segment of text tagged with which font it should be painted/measured +/// with (see [`RunKind`]). +struct TextRun { + start: usize, // byte offset + end: usize, // byte offset + kind: RunKind, +} + +/// True if `text` contains any character that wants emoji presentation +/// (#8-aware: honours the VS16 opt-in for text-presentation-default code +/// points, so plain "©"/"✓" no longer count as emoji here). pub fn has_emoji(text: &str) -> bool { - text.chars().any(is_emoji) + let chars: Vec = text.chars().collect(); + chars + .iter() + .enumerate() + .any(|(i, &c)| char_wants_emoji_font(c, chars.get(i + 1).copied())) } -/// Draw a text line with emoji font fallback. -/// If `emoji_font` is None, falls back to drawing everything with the primary font. +/// True if `text` needs the full run-segmentation machinery in +/// [`draw_text_with_fallback`]/[`measure_text_with_fallback`]: it contains +/// emoji-presentation content (and an emoji font is actually available), or +/// at least one non-whitespace/control code point the primary `font` +/// doesn't cover (#3). When neither is true, every caller's existing +/// single-font fast path is exactly correct and segmentation would be +/// wasted work. +fn needs_segmentation(text: &str, primary: &Font, emoji_font: &Option) -> bool { + if emoji_font.is_some() && has_emoji(text) { + return true; + } + text.chars().any(|c| { + // ASCII short-circuits before the `unichar_to_glyph` FFI call: every + // font this engine resolves covers printable ASCII, and callers + // that draw a lot of short spans per frame (codeblock's per-token + // syntax highlighting, in particular) call this once per span — + // skipping the Skia round-trip for the overwhelmingly common + // all-ASCII case keeps #3's fix from adding per-glyph FFI overhead + // to code that was never affected by the tofu/coverage bug it + // fixes. + !(c.is_ascii() || c.is_whitespace() || (c as u32) < 0x20) + && primary.unichar_to_glyph(c as i32) == 0 + }) +} + +/// Resolve which `Font` a run should actually be painted/measured with, +/// applying the emoji coverage guard (#8) and building a same-size `Font` +/// from a resolved fallback [`Typeface`] (#3). Returns a borrow of one of +/// the two inputs, or an owned font stored in `owned` to keep the borrow +/// alive at the call site. +fn resolve_run_font<'a>( + kind: &RunKind, + segment: &str, + primary: &'a Font, + emoji_font: &'a Option, + owned: &'a mut Option, +) -> &'a Font { + match kind { + RunKind::Primary => primary, + RunKind::Emoji => match emoji_font { + Some(ef) if font_covers(ef, segment.trim_end_matches(VARIATION_SELECTOR_EMOJI)) => ef, + // Coverage guard (#8): the emoji font doesn't actually have + // this glyph (or isn't available at all) — fall back to the + // primary font rather than paint `.notdef`. + _ => primary, + }, + RunKind::Fallback(tf) => { + *owned = Some(Font::from_typeface(tf.clone(), primary.size())); + owned.as_ref().unwrap() + } + } +} + +/// Draw a text line with emoji-font and glyph-coverage fallback (#3, #8). +/// If `emoji_font` is None, falls back to drawing everything with the +/// primary font (plus any `#3` system fallback the primary font's coverage +/// gap needs). pub fn draw_text_with_fallback( canvas: &Canvas, text: &str, @@ -224,8 +403,9 @@ pub fn draw_text_with_fallback( y: f32, paint: &Paint, ) { - // Fast path: no emoji font or no emoji in text - if emoji_font.is_none() || !has_emoji(text) { + // Fast path: nothing here needs emoji presentation or a glyph-coverage + // fallback — every earlier caller's exact previous behaviour. + if !needs_segmentation(text, font, emoji_font) { if letter_spacing.abs() > 0.01 { if let Some(blob) = make_text_blob_with_spacing(text, font, letter_spacing) { canvas.draw_text_blob(&blob, (x, y), paint); @@ -236,13 +416,13 @@ pub fn draw_text_with_fallback( return; } - let emoji_font = emoji_font.as_ref().unwrap(); - let runs = segment_text_runs(text); + let runs = segment_text_runs(text, font); let mut cursor_x = x; for run in &runs { let segment = &text[run.start..run.end]; - let f = if run.is_emoji { emoji_font } else { font }; + let mut owned = None; + let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned); if letter_spacing.abs() > 0.01 { if let Some(blob) = make_text_blob_with_spacing(segment, f, letter_spacing) { @@ -263,7 +443,9 @@ pub fn draw_text_with_fallback( } } -/// Measure the width of a text line with emoji font fallback. +/// Measure the width of a text line with emoji-font and glyph-coverage +/// fallback (#3, #8) — mirrors [`draw_text_with_fallback`] run-for-run so +/// the width this returns always matches what actually gets painted. pub fn measure_text_with_fallback( text: &str, font: &Font, @@ -271,7 +453,7 @@ pub fn measure_text_with_fallback( letter_spacing: f32, ) -> f32 { // Fast path - if emoji_font.is_none() || !has_emoji(text) { + if !needs_segmentation(text, font, emoji_font) { let (w, _) = font.measure_str(text, None); let extra = if letter_spacing.abs() > 0.01 { letter_spacing * (text.chars().count() as f32 - 1.0).max(0.0) @@ -281,13 +463,13 @@ pub fn measure_text_with_fallback( return w + extra; } - let emoji_font = emoji_font.as_ref().unwrap(); - let runs = segment_text_runs(text); + let runs = segment_text_runs(text, font); let mut total_w = 0.0f32; for run in &runs { let segment = &text[run.start..run.end]; - let f = if run.is_emoji { emoji_font } else { font }; + let mut owned = None; + let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned); let (w, _) = f.measure_str(segment, None); let extra = if letter_spacing.abs() > 0.01 { letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0) @@ -672,3 +854,350 @@ mod tracking_tests { } } } + +// ─── Tests: #8 — emoji presentation must default to text, not tofu/color ── + +#[cfg(test)] +mod emoji_presentation_tests { + use super::super::{emoji_typeface, typeface_with_fallback}; + use super::*; + + // These assertions are pure code-point classification — no font, no + // machine dependency — so they hold on every host. + + #[test] + fn text_presentation_default_symbols_are_not_emoji_without_vs16() { + // #8's headline repro: ✓ (U+2713) and ✔ (U+2714) — both inside the + // old unconditional 0x2702..=0x27B0 dingbats range — must NOT be + // classified as emoji when they appear bare, since Unicode marks + // them text-presentation by default. + assert!( + !char_wants_emoji_font('\u{2713}', None), + "✓ bare must be text" + ); + assert!( + !char_wants_emoji_font('\u{2714}', None), + "✔ bare must be text" + ); + // Copyright/registered/trademark: also text-presentation by default. + assert!( + !char_wants_emoji_font('\u{00A9}', None), + "© bare must be text" + ); + assert!( + !char_wants_emoji_font('\u{00AE}', None), + "® bare must be text" + ); + assert!( + !char_wants_emoji_font('\u{2122}', None), + "™ bare must be text" + ); + } + + #[test] + fn text_presentation_default_symbols_opt_into_emoji_with_vs16() { + // Explicit author intent (U+FE0F immediately after) must still be + // honoured — this is what "presentation *by default*" means. + assert!(char_wants_emoji_font('\u{2713}', Some('\u{FE0F}'))); + assert!(char_wants_emoji_font('\u{00A9}', Some('\u{FE0F}'))); + } + + #[test] + fn genuine_pictographs_are_always_emoji_regardless_of_vs16() { + // Regression guard: the narrowing must not touch the actual emoji + // blocks (grinning face, etc.) — these have no meaningful text + // rendering at all. + assert!( + char_wants_emoji_font('\u{1F600}', None), + "😀 must stay emoji" + ); + assert!(char_wants_emoji_font('\u{1F600}', Some('\u{FE0F}'))); + } + + #[test] + fn variation_selector_16_itself_is_always_emoji() { + // So it merges into whatever run selected it, rather than becoming + // its own (invisible, harmless either way) primary-font run. + assert!(char_wants_emoji_font('\u{FE0F}', None)); + } + + #[test] + fn variation_selector_15_does_not_force_emoji() { + // VS-15 (U+FE0E) forces *text* presentation — must not be conflated + // with VS-16 the way the old catch-all 0xFE00..=0xFE0F range did. + assert!(!char_wants_emoji_font('\u{2713}', Some('\u{FE0E}'))); + } + + #[test] + fn has_emoji_reflects_the_narrowed_classification() { + assert!(!has_emoji("2713:\u{2713} copyright:\u{00A9}")); + assert!(has_emoji("checked \u{2713}\u{FE0F}")); + assert!(has_emoji("grinning \u{1F600}")); + } + + // ---- render-level reproduction: color and coverage, no emoji font needed ---- + + fn helvetica_font(size: f32) -> Font { + let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal()) + .expect("host must have a fallback typeface"); + Font::from_typeface(typeface, size) + } + + /// Renders `text` at `size` in white and returns `(ink_pixel_count, + /// mean_r, mean_g, mean_b)` over every non-transparent pixel. + fn render_and_sample(text: &str, size: f32) -> (usize, f64, f64, f64) { + use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo}; + const W: i32 = 200; + const H: i32 = 200; + let font = helvetica_font(size); + let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, size)); + let mut surface = surfaces::raster_n32_premul((W, H)).unwrap(); + let canvas = surface.canvas(); + canvas.clear(Color::BLACK); + let mut paint = Paint::default(); + paint.set_color(Color::WHITE); + paint.set_anti_alias(true); + let (_, metrics) = font.metrics(); + let ascent = -metrics.ascent; + draw_text_with_fallback( + canvas, + text, + &font, + &emoji_font, + 0.0, + 10.0, + ascent + 10.0, + &paint, + ); + + let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut buf = vec![0u8; (W * H * 4) as usize]; + surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0)); + + let (mut n, mut sr, mut sg, mut sb) = (0usize, 0f64, 0f64, 0f64); + for px in buf.chunks_exact(4) { + if px[3] > 40 { + n += 1; + sr += px[0] as f64; + sg += px[1] as f64; + sb += px[2] as f64; + } + } + if n == 0 { + (0, 0.0, 0.0, 0.0) + } else { + (n, sr / n as f64, sg / n as f64, sb / n as f64) + } + } + + #[test] + fn bare_check_mark_paints_requested_white_not_tofu_or_color_bitmap() { + // #8 render-level reproduction, environment-independent: whether or + // not an emoji font is installed on this host is irrelevant here — + // with the fix, U+2713 alone is classified as *text*, so + // `draw_text_with_fallback` never even looks at `emoji_font` for + // it. Guard on the primary font actually covering the glyph so + // this doesn't fail on some exotic host where even Helvetica lacks + // it (the audit's own finding: Helvetica/Menlo DO have it, only + // Apple Color Emoji doesn't). + let font = helvetica_font(64.0); + if !font_covers(&font, "\u{2713}") { + eprintln!("skip: primary font doesn't cover U+2713 on this host"); + return; + } + let (ink, r, g, b) = render_and_sample("\u{2713}", 64.0); + assert!(ink > 20, "expected visible ink for ✓, got {ink} pixels"); + // White request -> painted channels should be bright and roughly + // neutral (not a dark/colored emoji-bitmap tint). + assert!( + r > 150.0 && g > 150.0 && b > 150.0, + "✓ should paint near-white, got mean rgb=({r:.0},{g:.0},{b:.0})" + ); + } +} + +// ─── Tests: #3 — glyph fallback for scripts the primary font doesn't cover ─ + +#[cfg(test)] +mod glyph_fallback_tests { + use super::super::{fallback_typeface_for_char, typeface_with_fallback}; + use super::*; + + fn helvetica_font(size: f32) -> Font { + let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal()) + .expect("host must have a fallback typeface"); + Font::from_typeface(typeface, size) + } + + #[test] + fn font_covers_detects_missing_glyphs_deterministically() { + // The detection mechanism itself, independent of whatever fallback + // font may or may not be installed: Helvetica (guaranteed present + // by `typeface_with_fallback`'s own contract) covers plain ASCII + // and does not cover CJK. + let font = helvetica_font(32.0); + assert!(font_covers(&font, "ABC"), "Helvetica must cover ASCII"); + assert!( + !font_covers(&font, "\u{4F60}\u{597D}"), // 你好 + "Helvetica must not cover CJK" + ); + } + + #[test] + fn classify_char_stays_primary_for_a_codepoint_no_font_covers() { + // A Private Use Area code point is uncovered by the primary font + // (nothing standard assigns glyphs there); it may or may not be + // "covered" by *some* installed font's own PUA convention (icon + // fonts, vendor glyphs) depending on the host, so skip gracefully + // rather than assume every machine agrees — the point of this test + // is that `classify_char` doesn't crash/loop when nothing covers a + // code point, falling back to `Primary` (the pre-fix, tofu- + // producing behaviour) rather than panicking. + let font = helvetica_font(32.0); + let pua = '\u{E000}'; + assert!( + !font_covers(&font, &pua.to_string()), + "test setup: PUA code point must be uncovered by the primary font" + ); + let style = font.typeface().font_style(); + let family = font.typeface().family_name(); + if fallback_typeface_for_char(&family, style, pua).is_some() { + eprintln!( + "skip: host has some font claiming to cover U+E000 (PUA) — can't exercise the \ + 'nothing covers it anywhere' branch on this host" + ); + return; + } + let kind = classify_char(pua, &font, None); + assert!( + matches!(kind, RunKind::Primary), + "an uncoverable-anywhere code point must resolve to Primary, not panic" + ); + } + + #[test] + fn classify_char_resolves_a_fallback_when_the_host_has_one() { + // Environment-dependent by nature (issue: CJK coverage depends on + // installed fonts) — skip gracefully, per this workstream's brief, + // rather than asserting a specific glyph renders. When a fallback + // *is* available (true on stock macOS/Windows/most Linux desktops + // via Noto/PingFang/MS-Gothic-class fonts), `classify_char` itself + // — not just the underlying `fallback_typeface_for_char` primitive + // — must actually route to it, and the resolved typeface must + // cover the character that triggered the lookup. + let font = helvetica_font(32.0); + let style = font.typeface().font_style(); + let family = font.typeface().family_name(); + if fallback_typeface_for_char(&family, style, '\u{4F60}').is_none() { + eprintln!("skip: no CJK-capable font installed on this host"); + return; + } + let kind = classify_char('\u{4F60}', &font, None); + let RunKind::Fallback(fallback) = kind else { + panic!( + "classify_char must resolve a Fallback run for an uncovered CJK code point when \ + the host has a capable font" + ); + }; + let fallback_font = Font::from_typeface(fallback, 32.0); + assert!( + font_covers(&fallback_font, "\u{4F60}"), + "resolved fallback typeface must actually cover the code point that triggered it" + ); + } + + #[test] + fn measure_and_paint_agree_on_cjk_width_when_fallback_is_available() { + // Measure-vs-paint parity (this workstream's core mandate): + // `measure_text_with_fallback` must report the width that actually + // gets painted. Skips gracefully (see above) when the host has no + // CJK-capable font at all — on such a host both measure and paint + // agree on the pre-fix degraded behaviour (0-width primary-font + // tofu), which is a separate, already-covered case. + let font = helvetica_font(48.0); + let text = "\u{4F60}\u{597D}"; // 你好 + if !text.chars().any(|c| { + fallback_typeface_for_char("Helvetica", font.typeface().font_style(), c).is_some() + }) { + eprintln!("skip: no CJK-capable font installed on this host"); + return; + } + + // Directly prove segmentation actually routes through the fallback + // mechanism (not just that `fallback_typeface_for_char` the + // primitive would resolve *something* if called) — this is what + // distinguishes "the fix is wired up" from "the fix exists but + // nothing calls it". + let runs = segment_text_runs(text, &font); + assert!( + runs.iter().any(|r| matches!(r.kind, RunKind::Fallback(_))), + "expected at least one Fallback run when segmenting CJK text with a fallback font \ + available, got kinds: {:?}", + runs.iter() + .map(|r| match &r.kind { + RunKind::Primary => "Primary", + RunKind::Emoji => "Emoji", + RunKind::Fallback(_) => "Fallback", + }) + .collect::>() + ); + + let measured_w = measure_text_with_fallback(text, &font, &None, 0.0); + // Not tofu-narrow: with a real CJK fallback, two full-width + // ideographs at 48px measure well beyond a couple of `.notdef` box + // glyphs' worth of width. + assert!( + measured_w > 30.0, + "expected a real CJK measurement, got suspiciously narrow {measured_w}" + ); + + use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo}; + const W: i32 = 400; + const H: i32 = 200; + let mut surface = surfaces::raster_n32_premul((W, H)).unwrap(); + let canvas = surface.canvas(); + canvas.clear(Color::BLACK); + let mut paint = Paint::default(); + paint.set_color(Color::WHITE); + paint.set_anti_alias(true); + let (_, metrics) = font.metrics(); + let ascent = -metrics.ascent; + draw_text_with_fallback(canvas, text, &font, &None, 0.0, 10.0, ascent + 10.0, &paint); + + // Ink detection: the canvas is cleared to opaque black (alpha=255 + // everywhere), so — unlike a transparent-cleared surface — alpha + // can't distinguish text from background here. White text against + // black shows up as a bright *red* (or green/blue) channel instead + // (same technique `tracking_tests::render_and_measure_ink_centre_x` + // above uses). + let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut buf = vec![0u8; (W * H * 4) as usize]; + surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0)); + let mut min_x: Option = None; + let mut max_x: Option = None; + for y in 0..H { + for x in 0..W { + let idx = ((y * W + x) * 4) as usize; + if buf[idx] > 40 { + min_x = Some(min_x.map_or(x, |m| m.min(x))); + max_x = Some(max_x.map_or(x, |m| m.max(x))); + } + } + } + let (min_x, max_x) = ( + min_x.expect("must paint something"), + max_x.expect("must paint something"), + ); + let painted_width = (max_x - min_x) as f32; + + // Loose tolerance: ink-bbox width vs advance width can differ from + // glyph side-bearings/overhang even for a well-behaved font — this + // is not the #125-style "wrap/paint disagreement" defect (a wild, + // multiple-hundred-px miscentre), just normal glyph-metric slack. + assert!( + (painted_width - measured_w).abs() < measured_w * 0.5 + 20.0, + "measured width {measured_w} should roughly match the painted ink width \ + {painted_width} (min_x={min_x}, max_x={max_x})" + ); + } +} diff --git a/crates/rustmotion-core/src/engine/text/cosmic.rs b/crates/rustmotion-core/src/engine/text/cosmic.rs index 2f52815..5af0656 100644 --- a/crates/rustmotion-core/src/engine/text/cosmic.rs +++ b/crates/rustmotion-core/src/engine/text/cosmic.rs @@ -9,6 +9,23 @@ //! Shaping & line-breaking are delegated to cosmic-text. Paint is done by //! rasterizing each glyph to an alpha mask, tinting it with the requested //! color, and blitting it as a small `Image` into Skia. +//! +//! **Not currently wired into the real render path (audit #10).** Every +//! component's actual measure/paint goes through `skia_safe::Font:: +//! measure_str` / `TextBlob::new` in `engine::renderer::text` + +//! `rustmotion-components::intrinsic::TextIntrinsic`, not through this +//! module — `measure_text`/`paint_text` below have no callers outside their +//! own tests (`grep -rn "engine::text\|text::cosmic" crates/` confirms +//! this). If you're chasing a text overflow/measure-vs-paint bug, look in +//! `engine::renderer::text.rs` and `rustmotion-components::intrinsic` +//! instead — the shaping/bidi/glyph-fallback behaviour cosmic-text would +//! provide here is not what actually renders today. Kept building (and the +//! `cosmic-text` dependency kept) as a candidate landing spot for a future +//! real shaping engine; not deleted unilaterally by this fix since that +//! call — wire it in for real vs. remove the module and its dependency — +//! is bigger than any single finding in this pass. See +//! `rustmotion-components::intrinsic` module doc for the other side of this +//! (it also used to claim a cosmic-text backing it doesn't have). use std::sync::{Mutex, OnceLock}; diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index cec5fd0..faa4ca7 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -560,11 +560,21 @@ mod camera_pan_tests { for (progress, expected) in [(0.0, [255u8, 0, 0]), (1.0, [0, 0, 255])] { let out = camera_pan_transition( - &bg, &bg, &fg_a, &fg_b, w, h, progress, 8.0, 0.0, - &EasingType::Linear, PanBackground::Static, + &bg, + &bg, + &fg_a, + &fg_b, + w, + h, + progress, + 8.0, + 0.0, + &EasingType::Linear, + PanBackground::Static, ); assert_eq!( - &out[0..3], &expected, + &out[0..3], + &expected, "at progress {progress} the adjacent scene must render untouched", ); } @@ -580,14 +590,26 @@ mod camera_pan_tests { let fg_b = solid(w, h, 0, 0, 255, 255); let out = camera_pan_transition( - &bg, &bg, &fg_a, &fg_b, w, h, 0.5, 8.0, 0.0, - &EasingType::Linear, PanBackground::Static, + &bg, + &bg, + &fg_a, + &fg_b, + w, + h, + 0.5, + 8.0, + 0.0, + &EasingType::Linear, + PanBackground::Static, ); // Left half carries the outgoing plane, right half the incoming one. let left_red = out[0]; let right_blue = out[((w - 1) * 4 + 2) as usize]; assert!(left_red > 128, "outgoing plane faded too far: {left_red}"); - assert!(right_blue > 128, "incoming plane still too faint: {right_blue}"); + assert!( + right_blue > 128, + "incoming plane still too faint: {right_blue}" + ); } fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec { diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 55e4a3f..02da951 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -161,8 +161,14 @@ pub enum RustmotionError { #[error("Failed to open FFmpeg stdin pipe")] FfmpegPipe, - #[error("Failed to write to FFmpeg pipe: {reason}")] - FfmpegWrite { reason: String }, + // A broken pipe here nearly always means ffmpeg already died on its own + // arguments, so the useful diagnostic is ffmpeg's stderr rather than our + // write error. Carry it in the error so it survives `--quiet`. + #[error("Failed to write to FFmpeg pipe: {reason}{}", .stderr.as_ref().map(|s| format!("\nffmpeg reported:\n{}", s)).unwrap_or_default())] + FfmpegWrite { + reason: String, + stderr: Option, + }, #[error("Failed to wait for FFmpeg: {reason}")] FfmpegWait { reason: String }, diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index 85260ab..ebd90d9 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -1,7 +1,14 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +// `deny_unknown_fields` (reliquat of wave-A's constat, PR #158): closes the +// last gap in `style.animation[*]` typo detection. Wave A covered the nine +// effect-config structs in `schema/video.rs`; the types *inside* a +// `keyframes[*]` entry (this struct and `Keyframe` below) were left +// uncovered — a typo'd key here (e.g. `duratoin`) used to be silently +// dropped instead of reported, same as every other struct this wave closed. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Animation { pub property: String, pub keyframes: Vec, @@ -12,6 +19,7 @@ pub struct Animation { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Keyframe { pub time: f64, pub value: KeyframeValue, @@ -194,3 +202,63 @@ impl Default for PresetConfig { fn default_preset_duration() -> f64 { 0.8 } + +#[cfg(test)] +mod deny_unknown_fields_tests { + use super::*; + use serde_json::json; + + // ---- reliquat of the wave-A fix (PR #158): `deny_unknown_fields` was + // added to the nine effect-config structs in `schema/video.rs`, but the + // types *inside* a `keyframes[*]` entry — `Animation` and `Keyframe`, + // both in this file — were left uncovered. A typo'd key inside one of + // these (e.g. `duratoin` on an `Animation`, or a per-keyframe field + // typo) used to be silently ignored instead of reported. This is the + // one change this workstream is authorized to make in this file. ---- + + #[test] + fn animation_rejects_unknown_fields() { + let json = json!({ + "property": "opacity", + "keyframes": [{ "time": 0.0, "value": 1.0 }], + "easing": "ease_out", + "duratoin": 5.0 + }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field on Animation must be rejected, not silently ignored"); + assert!(err.to_string().contains("duratoin"), "got: {err}"); + } + + #[test] + fn keyframe_rejects_unknown_fields() { + let json = json!({ "time": 0.0, "value": 1.0, "eaisng": "linear" }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field on Keyframe must be rejected, not silently ignored"); + assert!(err.to_string().contains("eaisng"), "got: {err}"); + } + + #[test] + fn animation_still_accepts_every_known_field() { + let json = json!({ + "property": "opacity", + "keyframes": [ + { "time": 0.0, "value": 0.0, "easing": "linear" }, + { "time": 1.0, "value": 1.0 } + ], + "easing": "ease_out", + "spring": { "damping": 10.0, "stiffness": 100.0, "mass": 1.0 } + }); + let a: Animation = serde_json::from_value(json).unwrap(); + assert_eq!(a.property, "opacity"); + assert_eq!(a.keyframes.len(), 2); + assert!(a.spring.is_some()); + } + + #[test] + fn keyframe_still_accepts_every_known_field() { + let json = json!({ "time": 0.5, "value": 10.0, "easing": "ease_in" }); + let k: Keyframe = serde_json::from_value(json).unwrap(); + assert_eq!(k.time, 0.5); + assert!(k.easing.is_some()); + } +} diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index 464d7ea..bfba1cb 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -159,6 +159,18 @@ impl Serialize for AnimatedBackground { } } +/// Every preset name the engine actually recognises. A `preset` value +/// outside this list — including the empty string produced when the key is +/// missing entirely — is rejected below instead of silently becoming +/// `gradient_shift` (constat #3, sink 1). +const KNOWN_BACKGROUND_PRESETS: &[&str] = &[ + "gradient_shift", + "grid_dots", + "concentric_circles", + "halo", + "heropattern", +]; + impl<'de> Deserialize<'de> for AnimatedBackground { fn deserialize>(deserializer: D) -> Result { let map: serde_json::Map = @@ -167,48 +179,32 @@ impl<'de> Deserialize<'de> for AnimatedBackground { // Common fields let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; - let direction: Option = map - .get("direction") - .and_then(|v| serde_json::from_value(v.clone()).ok()); + // Constat #3 (related sink, fixed alongside): a mistyped `direction` + // used to be swallowed by `.ok()` into a silent `None` — same class + // as the preset/zones/colors sinks below, just on a smaller field. + let direction: Option = match map.get("direction") { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| { + serde::de::Error::custom(format!("animated-background.direction: {e}")) + })?), + None => None, + }; let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or(""); + if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) { + return Err(serde::de::Error::custom(format!( + "unknown animated-background preset '{preset_str}': expected one of {}", + KNOWN_BACKGROUND_PRESETS.join(", ") + ))); + } // Detect new vs legacy format: new format has a sub-object keyed by preset name - let is_new_format = - !preset_str.is_empty() && map.get(preset_str).is_some_and(|v| v.is_object()); + let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object()); let (preset, speed) = if is_new_format { // New format: config in sub-object let sub = map.get(preset_str).unwrap().clone(); let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; - let preset = match preset_str { - "grid_dots" => { - let cfg: GridDotsConfig = - serde_json::from_value(sub).map_err(serde::de::Error::custom)?; - BackgroundPreset::GridDots(cfg) - } - "concentric_circles" => { - let cfg: ConcentricCirclesConfig = - serde_json::from_value(sub).map_err(serde::de::Error::custom)?; - BackgroundPreset::ConcentricCircles(cfg) - } - "halo" => { - let cfg: HaloConfig = - serde_json::from_value(sub).map_err(serde::de::Error::custom)?; - BackgroundPreset::Halo(cfg) - } - "heropattern" => { - let cfg: HeropatternConfig = - serde_json::from_value(sub).map_err(serde::de::Error::custom)?; - BackgroundPreset::Heropattern(cfg) - } - _ => { - // gradient_shift or unknown → gradient_shift - let cfg: GradientShiftConfig = - serde_json::from_value(sub).map_err(serde::de::Error::custom)?; - BackgroundPreset::GradientShift(cfg) - } - }; + let preset = deserialize_preset_config::(preset_str, sub)?; (preset, speed) } else { // Legacy flat format @@ -257,26 +253,74 @@ impl<'de> Deserialize<'de> for AnimatedBackground { }) } "halo" => { - let zones: Vec = map - .get("zones") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - BackgroundPreset::Halo(HaloConfig { zones }) + // Constat #3, sink 2: was `.ok().unwrap_or_default()` — + // a malformed (or entirely missing) `zones` silently + // became an empty halo instead of erroring. Route + // through the same validated-struct path as the + // new-format branch: `HaloConfig::zones` is required + // (no `#[serde(default)]`), so a missing/malformed value + // now produces a real "missing/invalid field zones" + // error instead. + let mut obj = serde_json::Map::new(); + if let Some(z) = map.get("zones") { + obj.insert("zones".to_string(), z.clone()); + } + let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj)) + .map_err(|e| { + serde::de::Error::custom(format!("animated-background.zones: {e}")) + })?; + BackgroundPreset::Halo(cfg) } - _ => { - // Default: gradient_shift - let colors: Vec = map - .get("colors") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - let gradient_type: GradientType = map - .get("gradient_type") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_else(default_bg_type); - BackgroundPreset::GradientShift(GradientShiftConfig { - colors, - gradient_type, - }) + "heropattern" => { + // Constat #3 (related sink, fixed alongside): the legacy + // branch never had an arm for `heropattern` at all, so a + // *correctly spelled* `"preset": "heropattern"` written + // in the legacy flat form (no `heropattern: {...}` + // sub-object) fell through the old `_ =>` wildcard and + // silently became `gradient_shift` with `colors: []`. + let mut obj = serde_json::Map::new(); + for key in ["pattern", "color", "opacity", "scale"] { + if let Some(v) = map.get(key) { + obj.insert(key.to_string(), v.clone()); + } + } + let cfg: HeropatternConfig = + serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| { + serde::de::Error::custom(format!( + "animated-background.heropattern: {e}" + )) + })?; + BackgroundPreset::Heropattern(cfg) + } + "gradient_shift" => { + // Constat #3, sink 3: `colors`/`gradient_type` were each + // parsed with `.ok().unwrap_or_default()` / + // `.ok().unwrap_or_else(default_bg_type)` — so even with + // `preset` spelled *correctly*, a missing or malformed + // `colors` silently produced `colors: []`, i.e. a fully + // empty gradient that paints black with no diagnostic at + // all — the exact worst-case symptom the audit names. + let mut obj = serde_json::Map::new(); + if let Some(c) = map.get("colors") { + obj.insert("colors".to_string(), c.clone()); + } + if let Some(g) = map.get("gradient_type") { + obj.insert("gradient_type".to_string(), g.clone()); + } + let cfg: GradientShiftConfig = + serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| { + serde::de::Error::custom(format!( + "animated-background.colors/gradient_type: {e}" + )) + })?; + BackgroundPreset::GradientShift(cfg) + } + // Unreachable: `preset_str` was already checked against + // `KNOWN_BACKGROUND_PRESETS` above. + other => { + return Err(serde::de::Error::custom(format!( + "internal error: unhandled animated-background preset '{other}'" + ))) } }; (preset, legacy_speed) @@ -305,6 +349,36 @@ impl<'de> Deserialize<'de> for AnimatedBackground { } } +/// Deserialize the preset-specific config object for the "new" nested +/// format (`{"preset": "halo", "halo": {...}}`) — shared by +/// `AnimatedBackground::deserialize` and available for reuse. `preset_str` +/// must already be one of [`KNOWN_BACKGROUND_PRESETS`]. +fn deserialize_preset_config( + preset_str: &str, + sub: serde_json::Value, +) -> Result { + match preset_str { + "grid_dots" => Ok(BackgroundPreset::GridDots( + serde_json::from_value(sub).map_err(E::custom)?, + )), + "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles( + serde_json::from_value(sub).map_err(E::custom)?, + )), + "halo" => Ok(BackgroundPreset::Halo( + serde_json::from_value(sub).map_err(E::custom)?, + )), + "heropattern" => Ok(BackgroundPreset::Heropattern( + serde_json::from_value(sub).map_err(E::custom)?, + )), + "gradient_shift" => Ok(BackgroundPreset::GradientShift( + serde_json::from_value(sub).map_err(E::custom)?, + )), + other => Err(E::custom(format!( + "internal error: unhandled animated-background preset '{other}'" + ))), + } +} + impl JsonSchema for AnimatedBackground { fn schema_name() -> String { "AnimatedBackground".to_string() @@ -401,6 +475,49 @@ pub struct BackgroundEntry { pub overrides: serde_json::Map, } +/// Constat #5: no derived `JsonSchema` here (the `#[serde(flatten)]` map +/// makes a fully-accurate derive impossible anyway — the point of `flatten` +/// is "any other keys"), which is exactly why `Scene`/`View` reached for +/// `#[schemars(skip)]` on `background` in the first place: skip was the +/// only option with no `JsonSchema` impl to call. But `Scene`/`View` are +/// also `deny_unknown_fields` (schemars emits `additionalProperties: false` +/// for that), so skipping `background` didn't just leave it undocumented — +/// it made the *exported schema* declare invalid any scenario that actually +/// sets `scene.background` / `view.background`, which is most of them. This +/// manual impl describes the real accepted shape (`$ref` + `transition` + +/// "anything else", matching the `flatten`) so `background` can be a real +/// declared property instead. +impl JsonSchema for BackgroundEntry { + fn schema_name() -> String { + "BackgroundEntry".to_string() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + use schemars::schema::*; + + let mut props = schemars::Map::new(); + props.insert("$ref".to_string(), gen.subschema_for::>()); + props.insert( + "transition".to_string(), + gen.subschema_for::>(), + ); + + SchemaObject { + instance_type: Some(InstanceType::Object.into()), + object: Some(Box::new(ObjectValidation { + properties: props, + // Mirrors `#[serde(flatten)] overrides: serde_json::Map<..>`: + // any other key (the preset config, `x`/`y`/`speed`/...) is + // genuinely accepted, not a schema gap to close. + additional_properties: Some(Box::new(Schema::Bool(true))), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + /// The unified background field: color string, single entry, or multiple entries. #[derive(Debug, Clone)] pub enum BackgroundValue { @@ -422,6 +539,40 @@ impl Serialize for BackgroundValue { } } +/// See [`BackgroundEntry`]'s `JsonSchema` impl doc comment — same reason: +/// `deserialize_background_value` is a hand-written `deserialize_with`, not +/// a derive, so there is no schema for schemars to infer without this. +impl JsonSchema for BackgroundValue { + fn schema_name() -> String { + "BackgroundValue".to_string() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + use schemars::schema::*; + + let string_schema = gen.subschema_for::(); + let entry_schema = gen.subschema_for::(); + let array_schema: Schema = SchemaObject { + instance_type: Some(InstanceType::Array.into()), + array: Some(Box::new(ArrayValidation { + items: Some(SingleOrVec::Single(Box::new(entry_schema.clone()))), + ..Default::default() + })), + ..Default::default() + } + .into(); + + SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + one_of: Some(vec![string_schema, entry_schema, array_schema]), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + /// Resolved background after template expansion — ready for rendering. #[derive(Debug, Clone, Default, Serialize)] pub struct ResolvedBackground { @@ -640,3 +791,162 @@ mod halo_zone_opacity_tests { } } } + +/// Constat #3: `AnimatedBackground::deserialize` had (at least) three silent +/// sinks — an unknown `preset` name silently became `gradient_shift` with +/// `colors: []`; a malformed/mistyped `zones` array in the legacy `halo` +/// form silently emptied via `.ok().unwrap_or_default()`; and a +/// malformed/missing `colors` (or `gradient_type`) on the legacy +/// `gradient_shift` form did the exact same `.ok().unwrap_or_default()` +/// silent-empty even when `preset` was spelled *correctly* — which is the +/// worst-case symptom named in the audit: an entirely black video with zero +/// diagnostics, because an empty-colors gradient paints black. Also found +/// (and fixed alongside, same root cause: the legacy branch's `_ =>` +/// wildcard): a *correctly spelled* `"heropattern"` preset written in the +/// legacy flat form (no `heropattern: {...}` sub-object) silently fell +/// through to `gradient_shift` too, because the legacy match only had +/// explicit arms for `grid_dots`/`concentric_circles`/`halo`. +#[cfg(test)] +mod animated_background_silent_sink_tests { + use super::*; + use serde_json::json; + + #[test] + fn known_preset_gradient_shift_still_works() { + let bg: AnimatedBackground = serde_json::from_value(json!({ + "preset": "gradient_shift", + "colors": ["#111111", "#222222"], + "gradient_type": "radial", + "speed": 10 + })) + .unwrap(); + match bg.preset { + BackgroundPreset::GradientShift(cfg) => { + assert_eq!(cfg.colors, vec!["#111111", "#222222"]); + assert!(matches!(cfg.gradient_type, GradientType::Radial)); + } + other => panic!("expected GradientShift, got {other:?}"), + } + } + + #[test] + fn unknown_preset_name_is_a_named_error_not_a_silent_black_gradient() { + let err = serde_json::from_value::(json!({ + "preset": "starfield", + "speed": 10 + })) + .expect_err("an unknown preset must be rejected, not silently treated as gradient_shift"); + let msg = err.to_string(); + assert!( + msg.contains("starfield"), + "error must name the offending preset value, got: {msg}" + ); + } + + #[test] + fn missing_preset_key_is_a_named_error() { + let err = serde_json::from_value::(json!({ "speed": 10 })) + .expect_err("a missing `preset` must be rejected, not silently treated as gradient_shift with colors: []"); + assert!( + err.to_string().to_lowercase().contains("preset"), + "got: {err}" + ); + } + + #[test] + fn legacy_halo_zones_still_work() { + let bg: AnimatedBackground = serde_json::from_value(json!({ + "preset": "halo", + "zones": [{ "color": "#1E3A8A", "x": 0.1, "y": 0.2, "radius": 0.3 }] + })) + .unwrap(); + match bg.preset { + BackgroundPreset::Halo(cfg) => assert_eq!(cfg.zones.len(), 1), + other => panic!("expected Halo, got {other:?}"), + } + } + + #[test] + fn legacy_halo_malformed_zones_is_a_named_error_not_a_silent_empty_zones() { + let err = serde_json::from_value::(json!({ + "preset": "halo", + "zones": [{ "color": "#1E3A8A", "x": "not-a-number" }] + })) + .expect_err("a malformed zones entry must be rejected, not silently emptied"); + assert!( + err.to_string().contains("zones") || err.to_string().contains("x"), + "error should point at the offending field, got: {err}" + ); + } + + #[test] + fn legacy_halo_missing_zones_is_a_named_error_not_a_silent_empty_zones() { + let err = serde_json::from_value::(json!({ "preset": "halo" })) + .expect_err("missing zones must be rejected, not silently treated as an empty halo"); + assert!(err.to_string().contains("zones"), "got: {err}"); + } + + #[test] + fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() { + // This is the exact worst-case symptom the audit names: preset is + // spelled *correctly*, but colors is missing/malformed -> silently + // empty colors -> a fully transparent gradient that paints black, + // with no diagnostic at all. + let err = serde_json::from_value::(json!({ + "preset": "gradient_shift", + "speed": 5 + })) + .expect_err("missing colors must error, not silently produce an empty (black) gradient"); + assert!(err.to_string().contains("colors"), "got: {err}"); + } + + #[test] + fn legacy_heropattern_is_recognised_not_silently_turned_into_gradient_shift() { + let bg: AnimatedBackground = serde_json::from_value(json!({ + "preset": "heropattern", + "pattern": "plus", + "color": "#ffffff", + "opacity": 0.2, + "scale": 1.5 + })) + .unwrap(); + match bg.preset { + BackgroundPreset::Heropattern(cfg) => { + assert_eq!(cfg.pattern, "plus"); + assert_eq!(cfg.scale, 1.5); + } + other => panic!("expected Heropattern, got {other:?}"), + } + } + + #[test] + fn legacy_heropattern_missing_pattern_is_a_named_error() { + let err = serde_json::from_value::(json!({ + "preset": "heropattern" + })) + .expect_err("heropattern with no pattern name must error"); + assert!(err.to_string().contains("pattern"), "got: {err}"); + } + + #[test] + fn direction_typo_is_a_named_error_not_a_silently_dropped_none() { + let err = serde_json::from_value::(json!({ + "preset": "grid_dots", + "colors": ["#fff"], + "direction": "diagonal" + })) + .expect_err("an unrecognised direction must be rejected, not silently dropped to None"); + assert!(err.to_string().contains("direction"), "got: {err}"); + } + + #[test] + fn direction_still_works_when_valid() { + let bg: AnimatedBackground = serde_json::from_value(json!({ + "preset": "grid_dots", + "colors": ["#fff"], + "direction": "up" + })) + .unwrap(); + assert!(matches!(bg.direction, Some(ScrollDirection::Up))); + } +} diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 119db03..357fc5e 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -130,8 +130,16 @@ pub struct View { #[serde(default)] pub transition: Option, /// (world) Shared background: color string, animated entry, or array. + // Constat #5: `background`'s `deserialize_with` bypasses the normal + // derive, so schemars had nothing to infer a schema from — hence the + // `#[schemars(skip)]` this used to carry. But `View` is also + // `deny_unknown_fields` (-> `additionalProperties: false` in the + // exported schema), so skipping the property didn't just leave it + // undocumented: it made the exported schema declare invalid every view + // that actually sets `background`. `BackgroundValue` now has a real + // (manual) `JsonSchema` impl — see `background.rs` — so this can be a + // normal declared property again. #[serde(default, deserialize_with = "deserialize_background_value")] - #[schemars(skip)] pub background: Option, /// (world) Legacy shared animated backgrounds. #[serde( @@ -341,8 +349,11 @@ pub struct WorldPosition { pub struct Scene { pub duration: f64, /// Unified background: color string, animated entry (with optional $ref), or array. + // Constat #5: see the identical note on `View::background` — same + // `#[schemars(skip)]` + `deny_unknown_fields` combination made the + // exported schema declare invalid every `examples/*.json` scene that + // sets `background` (which is most of them). #[serde(default, deserialize_with = "deserialize_background_value")] - #[schemars(skip)] pub background: Option, #[serde(default)] pub children: Vec, @@ -415,6 +426,46 @@ pub struct CameraOrigin { pub y: f32, } +/// Every camera property `interpolate_camera_property` +/// (`crates/rustmotion/src/engine/render/scene.rs`, owned by the sibling +/// GEO workstream this wave — read-only here) actually looks up via +/// `camera.keyframes.iter().find(|k| k.property == property)`. Constat #4: +/// a `CameraKeyframe.property` outside this fixed set (or the dotted +/// `origin.x`/`origin.y` convention misspelled as `origin_x`/`originX`) +/// never matches that lookup — the keyframe track is silently ignored and +/// the camera just uses its static value for that property, with no error. +const KNOWN_CAMERA_PROPERTIES: &[&str] = &["x", "y", "zoom", "rotation", "origin.x", "origin.y"]; + +fn validate_camera_property(value: &str) -> Result<(), E> { + if KNOWN_CAMERA_PROPERTIES.contains(&value) { + return Ok(()); + } + let normalize = |s: &str| s.replace(['-', '_', ' '], ".").to_lowercase(); + let normalized = normalize(value); + if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES + .iter() + .find(|known| normalize(known) == normalized) + { + Err(E::custom(format!( + "unknown camera keyframe property '{value}' — did you mean '{suggestion}'?" + ))) + } else { + Err(E::custom(format!( + "unknown camera keyframe property '{value}': expected one of {}", + KNOWN_CAMERA_PROPERTIES.join(", ") + ))) + } +} + +fn deserialize_camera_property<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + validate_camera_property::(&s)?; + Ok(s) +} + /// A keyframe for a camera property. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -422,6 +473,7 @@ pub struct CameraKeyframe { /// The camera property to animate: "x", "y", "zoom", "rotation", /// "origin.x", "origin.y" (dotted form, matching the component keyframe /// convention for compound properties). + #[serde(deserialize_with = "deserialize_camera_property")] pub property: String, /// Time-value pairs for the animation. pub values: Vec, @@ -908,3 +960,57 @@ mod strict_schema_tests { assert_eq!(s.scenes.len(), 1); } } + +/// Constat #4 (camera half): `CameraKeyframe.property` is consumed by +/// `interpolate_camera_property` in `crates/rustmotion/src/engine/render/ +/// scene.rs` (read-only for this workstream — owned by the sibling GEO +/// workstream this wave), which looks up +/// `camera.keyframes.iter().find(|k| k.property == property)` for each of a +/// *fixed* set of six properties (`"x"`, `"y"`, `"zoom"`, `"rotation"`, +/// `"origin.x"`, `"origin.y"`). A misspelled or wrongly-cased +/// `CameraKeyframe.property` simply never matches that lookup — the track +/// silently falls back to the camera's static value and never animates, +/// with no error anywhere. +#[cfg(test)] +mod camera_keyframe_property_tests { + use super::*; + + #[test] + fn known_camera_properties_still_work() { + for prop in ["x", "y", "zoom", "rotation", "origin.x", "origin.y"] { + let json = format!( + r#"{{ "property": "{prop}", "values": [ {{ "time": 0.0, "value": 1.0 }} ] }}"# + ); + let kf: CameraKeyframe = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("property '{prop}' must be accepted, got: {e}")); + assert_eq!(kf.property, prop); + } + } + + #[test] + fn unknown_camera_property_is_a_named_error_not_a_silent_no_op() { + let json = r#"{ "property": "tilt", "values": [ { "time": 0.0, "value": 1.0 } ] }"#; + let err = serde_json::from_str::(json).expect_err( + "an unrecognised camera keyframe property must be rejected, not silently inert", + ); + assert!(err.to_string().contains("tilt"), "got: {err}"); + } + + #[test] + fn misspelled_origin_property_is_a_named_error() { + // The documented dotted-compound-property convention + // (`origin.x`/`origin.y`) is easy to get wrong (`originX`, + // `origin_x`) — before this fix, any of those silently never + // animated the camera origin, with the keyframes block accepted + // and simply ignored. + let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#; + let err = serde_json::from_str::(json) + .expect_err("origin_x must be rejected — the real property is origin.x"); + let msg = err.to_string(); + assert!(msg.contains("origin_x"), "got: {msg}"); + assert!( + msg.contains("origin.x"), + "expected a did-you-mean nudge toward origin.x, got: {msg}" + ); + } +} diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 9c4acc0..0f6b7a0 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -173,7 +173,17 @@ impl AnimationEffect { } /// Timing configuration for preset animations. +// `deny_unknown_fields` (constat #8): this is the `AnimationTiming` payload +// of an internally-tagged `AnimationEffect` variant (`#[serde(tag = "name")]` +// on the enum). Serde's tagged-enum deserializer buffers the object and +// re-drives it through the variant's own `Deserialize` impl *without* the +// `name` tag key, so `deny_unknown_fields` here rejects a typo'd field (e.g. +// `duratoin`) without ever seeing/rejecting `name` itself — verified with a +// minimal repro before relying on it. Without this, `validate_attrs.rs` +// never sees inside `style.animation[*]` (it only walks component-level +// keys), so a typo silently no-ops instead of erroring. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct AnimationTiming { /// Delay before animation starts (seconds). #[serde(default)] @@ -193,6 +203,13 @@ pub struct AnimationTiming { /// this overrides them. #[serde(default)] pub spring: Option, + /// Travel of an oscillating preset, in pixels (`float_3d` only; default + /// 12). Threaded through `to_preset_config` into `PresetConfig::amplitude`, + /// which `expand_preset_inner` already reads — this field is what makes + /// an author-supplied amplitude actually reach it instead of the + /// hardcoded default on every element. + #[serde(default)] + pub amplitude: Option, } fn default_animation_duration() -> f64 { @@ -201,6 +218,7 @@ fn default_animation_duration() -> f64 { /// Configuration for the `tilt_in` animation with configurable 3D transform values. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TiltInConfig { #[serde(default)] pub delay: f64, @@ -230,12 +248,14 @@ impl Default for AnimationTiming { repeat: false, overshoot: None, spring: None, + amplitude: None, } } } /// Timing configuration for char animation effect variants (used inside style.animation). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct CharAnimationTiming { /// Delay before animation starts (seconds). #[serde(default)] @@ -347,7 +367,7 @@ impl AnimationTiming { /// Convert to PresetConfig for compatibility with resolve_animations. pub fn to_preset_config(&self) -> PresetConfig { PresetConfig { - amplitude: None, + amplitude: self.amplitude, delay: self.delay, duration: self.duration, repeat: self.repeat, @@ -357,9 +377,113 @@ impl AnimationTiming { } } +/// Constat #4: every `property` name `engine::animator::{apply_property, +/// get_property_value}` (read-only for this workstream — the solver logic +/// itself stays there) actually recognises for `wiggle`/`keyframes` +/// animations. Anything outside this set has always been a silent no-op in +/// the solver (`_ => {}` / `_ => 0.0`): the animation plays as if the +/// property doesn't exist, with no error and no visual signal that +/// something is wrong. `WiggleConfig.property` and `Animation.property` +/// (the latter via `KeyframesConfig.keyframes`'s `deserialize_with`, since +/// `Animation` itself lives in `schema/animation.rs`, which this workstream +/// may only touch for `deny_unknown_fields`) are validated against this set +/// at parse time instead — turning the silent no-op into a named error, so +/// a mixed-convention typo (`"translateX"`, `"positionX"`, `"Rotation"`) or +/// a wholesale unsupported name is caught immediately. +/// +/// `"color"` is included because `resolve_animations` special-cases +/// `anim.property == "color"` outside `apply_property`/`get_property_value` +/// — it is a real, solver-recognised value for `Animation`, just resolved on +/// a different path than the numeric properties. +const KNOWN_MOTION_PROPERTIES: &[&str] = &[ + "opacity", + "position.x", + "translate_x", + "position.y", + "translate_y", + "scale", + "scale.x", + "scale.y", + "rotation", + "rotate_x", + "rotate_y", + "blur", + "visible_chars", + "visible_chars_progress", + "border_radius", + "font_size", + "width", + "height", + "gap", + "padding", + "stroke_width", + "shadow_blur", + "glow_radius", + "glow_intensity", + "perspective", + "draw_progress", + "motion_progress", + "color", +]; + +/// Reject a `property` value the solver doesn't recognise, with a +/// "did-you-mean" nudge when the only mismatch is casing/separator +/// convention (`translateX` / `translate-x` vs `translate_x`) — the exact +/// trap constat #4 names: this project mixes kebab-case (CSS-style, most of +/// `CssStyle`) and snake_case (these property names) conventions, and an +/// author reasoning from the former naturally reaches for the latter's +/// kebab or camelCase spelling. +fn validate_motion_property(value: &str) -> Result<(), E> { + if KNOWN_MOTION_PROPERTIES.contains(&value) { + return Ok(()); + } + let normalize = |s: &str| s.replace(['-', ' '], "_").to_lowercase(); + let normalized = normalize(value); + if let Some(suggestion) = KNOWN_MOTION_PROPERTIES + .iter() + .find(|known| normalize(known) == normalized) + { + Err(E::custom(format!( + "unknown animation property '{value}' — did you mean '{suggestion}'?" + ))) + } else { + Err(E::custom(format!( + "unknown animation property '{value}': expected one of {}", + KNOWN_MOTION_PROPERTIES.join(", ") + ))) + } +} + +fn deserialize_motion_property<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + validate_motion_property::(&s)?; + Ok(s) +} + +/// Validates every keyframe's `property` the same way +/// [`deserialize_motion_property`] does for `WiggleConfig` — `Animation` +/// itself lives in `schema/animation.rs`, out of reach for anything beyond +/// `deny_unknown_fields` in this workstream, so the check is applied here, +/// at the one field that actually consumes `Vec` in this file. +fn deserialize_validated_keyframes<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let animations = Vec::::deserialize(deserializer)?; + for anim in &animations { + validate_motion_property::(&anim.property)?; + } + Ok(animations) +} + /// Custom keyframe animations configuration. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct KeyframesConfig { + #[serde(deserialize_with = "deserialize_validated_keyframes")] pub keyframes: Vec, #[serde(default)] pub delay: f64, @@ -379,6 +503,7 @@ pub struct KeyframesConfig { /// `samples = 1` is the degenerate case: the single ghost falls at `t - 0` and /// superimposes exactly on the principal → visually equivalent to no blur. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct MotionBlurConfig { /// Reserved for future intensity scaling (currently unused by the ghost /// sampler — the `samples` parameter controls quality). Kept for schema @@ -409,6 +534,7 @@ fn default_motion_blur_shutter() -> f64 { /// `base_opacity * falloff^i`; the principal is unchanged. Unlike motion blur, /// the trail is additive: the principal retains its full opacity. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct TrailConfig { /// Number of trailing ghost copies (default 4, clamped 1..=12). #[serde(default = "default_trail_copies")] @@ -437,6 +563,7 @@ fn default_trail_falloff() -> f32 { /// Configuration for a 3D orbit/floating animation effect. /// Creates circular or elliptical motion with pseudo-depth (scale + opacity modulation). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct OrbitConfig { /// Horizontal radius of the orbit in pixels. #[serde(default = "default_orbit_radius")] @@ -477,7 +604,9 @@ fn default_orbit_depth() -> f64 { // --- Wiggle Config --- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct WiggleConfig { + #[serde(deserialize_with = "deserialize_motion_property")] pub property: String, pub amplitude: f64, pub frequency: f64, @@ -667,6 +796,7 @@ pub struct TextBackground { /// Glow effect (colored luminous halo around the element) #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct GlowConfig { /// Glow color (hex string, e.g. "#5C39EE") #[serde(default = "default_glow_color")] @@ -732,3 +862,120 @@ fn default_shadow_blur() -> f32 { fn default_text_bg_padding() -> f32 { 8.0 } + +#[cfg(test)] +mod motion_property_tests { + use super::*; + use serde_json::json; + + // ---- constat #4: `WiggleConfig.property` / `Animation.property` (via + // `KeyframesConfig.keyframes`) are free strings the solver silently + // no-ops on when unrecognised (RED first). ---- + + #[test] + fn wiggle_known_property_still_works() { + let json = json!({ + "name": "wiggle", + "property": "translate_x", + "amplitude": 10.0, + "frequency": 1.0 + }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + match effect { + AnimationEffect::Wiggle(cfg) => assert_eq!(cfg.property, "translate_x"), + other => panic!("expected Wiggle, got {other:?}"), + } + } + + #[test] + fn wiggle_unknown_property_is_a_named_error_not_a_silent_no_op() { + // A wholly unsupported name — the animation would otherwise play, + // resolve every frame, and simply never touch any rendered + // property: no error, no visible effect, no signal at all. + let json = json!({ + "name": "wiggle", + "property": "skew", + "amplitude": 10.0, + "frequency": 1.0 + }); + let err = serde_json::from_value::(json) + .expect_err("an unrecognised wiggle property must be rejected, not silently inert"); + assert!(err.to_string().contains("skew"), "got: {err}"); + } + + #[test] + fn wiggle_kebab_case_property_gets_a_did_you_mean() { + // The exact trap named in constat #4: this project mixes kebab-case + // (CSS-style, most of `CssStyle`) and snake_case (these property + // names) conventions across files, so an author reasoning in + // kebab-case naturally writes `translate-x` instead of the + // solver's `translate_x` — silently inert before this fix. + let json = json!({ + "name": "wiggle", + "property": "translate-x", + "amplitude": 10.0, + "frequency": 1.0 + }); + let err = serde_json::from_value::(json) + .expect_err("kebab-case must not silently resolve to a snake_case no-op"); + let msg = err.to_string(); + assert!(msg.contains("translate-x"), "got: {msg}"); + assert!( + msg.contains("translate_x"), + "expected a did-you-mean nudge toward the correct spelling, got: {msg}" + ); + } + + #[test] + fn keyframes_animation_known_property_still_works() { + let json = json!({ + "name": "keyframes", + "keyframes": [ + { "property": "opacity", "keyframes": [ + { "time": 0.0, "value": 0.0 }, + { "time": 1.0, "value": 1.0 } + ]} + ] + }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + match effect { + AnimationEffect::Keyframes(cfg) => assert_eq!(cfg.keyframes[0].property, "opacity"), + other => panic!("expected Keyframes, got {other:?}"), + } + } + + #[test] + fn keyframes_animation_unknown_property_is_a_named_error() { + let json = json!({ + "name": "keyframes", + "keyframes": [ + { "property": "positionX", "keyframes": [ + { "time": 0.0, "value": 0.0 }, + { "time": 1.0, "value": 1.0 } + ]} + ] + }); + let err = serde_json::from_value::(json).expect_err( + "an unrecognised keyframe animation property must be rejected, not silently inert", + ); + assert!(err.to_string().contains("positionX"), "got: {err}"); + } + + #[test] + fn keyframes_animation_color_property_still_works() { + // "color" is solver-recognised (special-cased in + // `resolve_animations`, outside `apply_property`), not a numeric + // motion property — must not be rejected. + let json = json!({ + "name": "keyframes", + "keyframes": [ + { "property": "color", "keyframes": [ + { "time": 0.0, "value": "#000000" }, + { "time": 1.0, "value": "#ffffff" } + ]} + ] + }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + assert!(matches!(effect, AnimationEffect::Keyframes(_))); + } +} diff --git a/crates/rustmotion-core/src/variables.rs b/crates/rustmotion-core/src/variables.rs index d17e806..429b08a 100644 --- a/crates/rustmotion-core/src/variables.rs +++ b/crates/rustmotion-core/src/variables.rs @@ -257,17 +257,6 @@ pub fn apply_variables( map.remove("config"); } substitute(value, &merged, path)?; - - // Check for unresolved references - let unresolved = find_unresolved(value); - if let Some(name) = unresolved.into_iter().next() { - return Err(RustmotionError::UnresolvedVariable { - name, - path: path.to_string(), - }); - } - - Ok(()) } None => { // No config block. If overrides were supplied (e.g. from the CLI for an HTML @@ -277,9 +266,44 @@ pub fn apply_variables( substitute(value, ovr, path)?; } } - Ok(()) } } + + // Constat #7: `find_unresolved` used to run — and hard-fail the whole + // render/validate on its first hit — *only* inside the `Some(defs)` + // branch above, so the exact same leftover `$word` (a price tag, a + // terminal `$PATH`, a shell `$HOME`) was harmless in a document with no + // `config` block and fatal the moment an unrelated `config` block + // existed anywhere else in the same file. `find_unresolved` cannot + // structurally tell a genuine unresolved-reference typo apart from + // incidental literal-`$` content — by construction, every name in + // `defs` above is always present in `merged` (defaults ∪ overrides), so + // `substitute` can never leave a *declared* variable name unresolved; + // everything `find_unresolved` can still find here is, definitionally, + // *not* one of the variables this document declared. So: run the same + // scan unconditionally (fixing the "depends on an unrelated key" + // inconsistency), but report it as a loud warning rather than aborting + // the whole document — same fail-loud-not-silent contract already used + // elsewhere in this workstream (see `css::units::px_or_warn`), applied + // here because a hard rejection would break any existing scenario that + // legitimately has a `$` in its content and would newly break every one + // of those the moment it also gained a `config` block. + for name in find_unresolved(value) { + // Reuse `UnresolvedVariable`'s existing `Display` message (see + // `error.rs`) for the warning text instead of hand-rolling a new + // one — this is the same diagnostic, just no longer fatal. + let diagnostic = RustmotionError::UnresolvedVariable { + name, + path: path.to_string(), + }; + eprintln!( + "Warning: {diagnostic} — either a typo'd variable name or literal '$' content (a \ + price, a shell $PATH, ...); the literal text is kept as-is instead of failing the \ + render." + ); + } + + Ok(()) } /// For standalone rendering: apply defaults only (no overrides). @@ -461,4 +485,117 @@ mod tests { substitute(&mut val, &vars, "test").unwrap(); assert_eq!(val["text"], json!("Count: 42 items")); } + + // ---- constat #7: literal `$` fatality must not depend on an unrelated + // `config` key (RED first) ---- + + /// A document with **no** `config` block and a literal `$` in unrelated + /// content (a `terminal` line's `$PATH`) — this already succeeds today + /// (the bug is the *other* direction; this locks in it keeps working). + fn doc_with_literal_dollar_no_config() -> serde_json::Value { + json!({ + "video": { "width": 1080, "height": 1920 }, + "scenes": [{ + "duration": 3.0, + "children": [ + { "type": "terminal", "lines": ["echo $PATH", "cd $HOME/project"] }, + { "type": "text", "content": "Price: $100 today only" } + ] + }] + }) + } + + /// The exact same literal-`$` content, but the document also happens to + /// declare an unrelated `config` block (e.g. because it's a reusable + /// template with one templated field). Before the fix, this made + /// `apply_variables` return `Err(UnresolvedVariable)` and abort the + /// entire render/validate — for content the config block has nothing to + /// do with. + fn doc_with_literal_dollar_and_unrelated_config() -> serde_json::Value { + json!({ + "config": { + "title": { "type": "string", "default": "Demo" } + }, + "video": { "width": 1080, "height": 1920 }, + "scenes": [{ + "duration": 3.0, + "children": [ + { "type": "text", "content": "$title" }, + { "type": "terminal", "lines": ["echo $PATH", "cd $HOME/project"] }, + { "type": "text", "content": "Price: $100 today only" } + ] + }] + }) + } + + #[test] + fn literal_dollar_without_config_block_already_succeeds() { + let mut doc = doc_with_literal_dollar_no_config(); + apply_defaults(&mut doc).expect( + "a literal '$' in terminal/text content with no config block must not be fatal", + ); + // Content is left as-is: nothing declared these as variables. + assert_eq!( + doc["scenes"][0]["children"][0]["lines"][0], + json!("echo $PATH") + ); + } + + #[test] + fn literal_dollar_with_unrelated_config_block_must_not_be_fatal() { + // RED before the fix: this currently returns + // `Err(UnresolvedVariable { name: "PATH", .. })` (or "HOME", or + // "100", whichever `find_unresolved` reaches first) purely because + // *some* config block exists elsewhere in the same document — the + // exact inconsistency named in constat #7. The declared `$title` + // variable must still resolve correctly either way. + let mut doc = doc_with_literal_dollar_and_unrelated_config(); + apply_defaults(&mut doc).expect( + "a literal '$' in unrelated content must not become fatal just because the \ + document also happens to declare an unrelated `config` block", + ); + assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Demo")); + assert_eq!( + doc["scenes"][0]["children"][1]["lines"][0], + json!("echo $PATH") + ); + assert_eq!( + doc["scenes"][0]["children"][2]["content"], + json!("Price: $100 today only") + ); + } + + #[test] + fn undeclared_override_is_still_a_hard_error_unaffected_by_the_fix() { + // The other half of `apply_variables`'s error surface (an override + // key that doesn't match any declared variable) is a genuine, + // unambiguous user error — unrelated to the literal-`$`-in-content + // ambiguity — and must remain a hard error. + let mut doc = json!({ + "config": { "title": { "type": "string", "default": "Demo" } }, + "video": { "width": 1, "height": 1 }, + "scenes": [] + }); + let mut overrides = HashMap::new(); + overrides.insert("nope".to_string(), json!("x")); + let err = apply_variables(&mut doc, Some(&overrides), "test.json") + .expect_err("an override referencing an undeclared variable must still be rejected"); + assert!(matches!( + err, + crate::error::RustmotionError::UndefinedVariable { .. } + )); + } + + #[test] + fn declared_variable_reference_still_resolves_with_no_override() { + let mut doc = json!({ + "config": { "greeting": { "type": "string", "default": "Hello" } }, + "video": { "width": 1, "height": 1 }, + "scenes": [{ "duration": 1.0, "children": [ + { "type": "text", "content": "$greeting" } + ]}] + }); + apply_defaults(&mut doc).unwrap(); + assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Hello")); + } } diff --git a/crates/rustmotion-core/tests/exported_schema_examples.rs b/crates/rustmotion-core/tests/exported_schema_examples.rs new file mode 100644 index 0000000..387625e --- /dev/null +++ b/crates/rustmotion-core/tests/exported_schema_examples.rs @@ -0,0 +1,234 @@ +//! Constat #5: `rustmotion schema` used to export a `Scene`/`View` with +//! `additionalProperties: false` (from `deny_unknown_fields`) but no +//! `background` property (from `#[schemars(skip)]` on a field with no +//! `JsonSchema` impl for its type). The two combined meant the exported +//! schema declared *every* scenario in `examples/` that uses `background` +//! invalid — the schema is exactly what generators (LLMs) are meant to +//! target, so this is the sink 3 users can't work around. +//! +//! This test is a minimal, dependency-free (no external JSON-Schema crate — +//! adding one is out of this workstream's file scope) structural validator: +//! it understands exactly the subset of JSON Schema draft-07 that +//! `schemars` 0.8 actually emits for this codebase (`$ref`, `definitions`, +//! `type`, `properties`/`additionalProperties`/`required`, `items`, +//! `oneOf`/`anyOf`/`allOf`, `enum`). It is not a general-purpose validator, +//! but it is precise about the one thing constat #5 is about: +//! `additionalProperties: false` combined with a missing declared property. + +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::PathBuf; + +fn examples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +/// Resolve a `$ref` like `#/definitions/Scene` against the schema root. +fn resolve<'a>(root: &'a Value, ref_str: &str) -> &'a Value { + let path = ref_str.strip_prefix("#/").unwrap_or(ref_str); + let mut cur = root; + for part in path.split('/') { + cur = cur + .get(part) + .unwrap_or_else(|| panic!("dangling $ref segment '{part}' in '{ref_str}'")); + } + cur +} + +/// Validate `instance` against `schema` (a node within `root`). Appends a +/// human-readable message to `errors` for every violation found, prefixed +/// with `path`. This intentionally does not stop at the first violation +/// (matches how the equivalent Python `jsonschema` run was cross-checked). +fn check(root: &Value, schema: &Value, instance: &Value, path: &str, errors: &mut Vec) { + // `true` / `{}` accept anything. + if schema.as_bool() == Some(true) { + return; + } + if let Some(obj) = schema.as_object() { + if obj.is_empty() { + return; + } + } + + if let Some(r) = schema.get("$ref").and_then(|v| v.as_str()) { + check(root, resolve(root, r), instance, path, errors); + return; + } + + if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) { + for sub in all_of { + check(root, sub, instance, path, errors); + } + } + + if let Some(variants) = schema + .get("oneOf") + .or_else(|| schema.get("anyOf")) + .and_then(|v| v.as_array()) + { + let mut best: Option> = None; + for variant in variants { + let mut sub_errors = Vec::new(); + check(root, variant, instance, path, &mut sub_errors); + if sub_errors.is_empty() { + return; // one matching variant is enough + } + if best.as_ref().is_none_or(|b| sub_errors.len() < b.len()) { + best = Some(sub_errors); + } + } + if let Some(b) = best { + errors.push(format!( + "{path}: matched no oneOf/anyOf variant (closest variant errors: {b:?})" + )); + } + return; + } + + if let Some(expected) = schema.get("enum").and_then(|v| v.as_array()) { + if !expected.contains(instance) { + errors.push(format!("{path}: {instance} is not one of {expected:?}")); + } + return; + } + + if let Some(ty) = schema.get("type").and_then(|v| v.as_str()) { + let matches = match ty { + "object" => instance.is_object(), + "array" => instance.is_array(), + "string" => instance.is_string(), + "number" => instance.is_number(), + "integer" => instance.is_i64() || instance.is_u64(), + "boolean" => instance.is_boolean(), + "null" => instance.is_null(), + _ => true, + }; + if !matches { + errors.push(format!("{path}: expected type {ty}, got {instance}")); + return; + } + } + + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + if let Some(inst_obj) = instance.as_object() { + let required: BTreeSet<&str> = schema + .get("required") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + for key in &required { + if !inst_obj.contains_key(*key) { + errors.push(format!("{path}: missing required field '{key}'")); + } + } + let additional = schema.get("additionalProperties"); + for (k, v) in inst_obj { + if let Some(sub_schema) = props.get(k) { + check(root, sub_schema, v, &format!("{path}/{k}"), errors); + } else { + match additional { + Some(Value::Bool(false)) => { + errors.push(format!( + "{path}: additional property '{k}' is not allowed by the schema \ + (declared properties: {:?})", + props.keys().collect::>() + )); + } + Some(Value::Bool(true)) | None => {} + Some(sub_schema) => { + check(root, sub_schema, v, &format!("{path}/{k}"), errors); + } + } + } + } + } + } + + if let Some(items_schema) = schema.get("items") { + if let Some(arr) = instance.as_array() { + for (i, item) in arr.iter().enumerate() { + check(root, items_schema, item, &format!("{path}[{i}]"), errors); + } + } + } +} + +/// Every `examples/*.json` file must validate against the schema +/// `rustmotion schema` exports (i.e. `generate_json_schema()` — the CLI +/// command only additionally wires `Scene.children` to the `Component` +/// union, which is irrelevant to constat #5's `background` defect and out +/// of this workstream's file scope to reproduce here). +/// +/// `ferriskey-presentation.json` is excluded: it fails plain `rustmotion +/// validate` today for an unrelated, pre-existing geometry overflow (issue +/// #157, out of this workstream's scope) — but per the baseline run below, +/// it has zero *schema* violations even before this fix, so excluding it +/// from the loop changes nothing about what this test proves. +#[test] +fn all_examples_validate_against_the_exported_schema() { + let schema = rustmotion_core::schema::generate_json_schema(); + let mut failures = Vec::new(); + + let mut count = 0; + for entry in std::fs::read_dir(examples_dir()).expect("examples/ dir must exist") { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + count += 1; + let raw = std::fs::read_to_string(&path).unwrap(); + let doc: Value = serde_json::from_str(&raw).unwrap(); + let mut errors = Vec::new(); + check( + &schema, + &schema, + &doc, + path.file_name().unwrap().to_str().unwrap(), + &mut errors, + ); + if !errors.is_empty() { + failures.push(format!( + "{}: {} violation(s), first: {}", + path.display(), + errors.len(), + errors[0] + )); + } + } + + assert!( + count >= 8, + "expected at least 8 example files, found {count}" + ); + assert!( + failures.is_empty(), + "the following examples/*.json fail to validate against `rustmotion schema`'s output:\n{}", + failures.join("\n") + ); +} + +/// Narrower, more direct regression lock for the exact defect: `Scene` and +/// `View` must both declare `background` as a property in the exported +/// schema. Kept alongside the full-document check above because this is +/// the precise structural fact constat #5 is about, independent of whatever +/// else may be in the document. +#[test] +fn scene_and_view_schema_both_declare_a_background_property() { + let schema = rustmotion_core::schema::generate_json_schema(); + for name in ["Scene", "View"] { + let def = schema + .pointer(&format!("/definitions/{name}")) + .unwrap_or_else(|| panic!("no definitions/{name} in exported schema")); + assert_eq!( + def.get("additionalProperties"), + Some(&Value::Bool(false)), + "{name} must still be closed to unknown fields (deny_unknown_fields)" + ); + assert!( + def.pointer("/properties/background").is_some(), + "{name} must declare `background` as a property — it is a real, accepted field \ + (deserialize_background_value), not schemars(skip)-worthy dead schema" + ); + } +} diff --git a/crates/rustmotion-html/src/element.rs b/crates/rustmotion-html/src/element.rs index 66db5c2..c308265 100644 --- a/crates/rustmotion-html/src/element.rs +++ b/crates/rustmotion-html/src/element.rs @@ -7,6 +7,16 @@ use crate::{element_attrs, tag_name, HtmlError}; enum TagKind { Container, Text, + /// Tags that never visually render in real HTML either (`

real

"#); + let children = v["children"].as_array().expect("children array"); + assert_eq!( + children.len(), + 1, + "script content must not become a component: {v}" + ); + assert_eq!(children[0]["content"], json!("real")); + } + + #[test] + fn title_and_noscript_and_template_elements_are_skipped_not_painted() { + let v = map_first( + r#"
tt

real

"#, + ); + let children = v["children"].as_array().expect("children array"); + assert_eq!( + children.len(), + 1, + "title/noscript/template content must not become a component: {v}" + ); + assert_eq!(children[0]["content"], json!("real")); + } + + #[test] + fn tag_kind_head_is_ignored() { + // content never survives as a distinct DOM node when authored + // inline (html5ever drops the wrapper per HTML5 "in body" parsing + // rules and lets its text bleed into the parent), so this can only be + // exercised at the `tag_kind` unit level, not through the full + // element_to_value/html_to_scenario_value pipeline. + assert!(matches!(tag_kind("head"), TagKind::Ignored)); + } + + // --- unsupported native elements (constat 3) --- + + #[test] + fn img_element_is_refused_with_rm_image_suggestion() { + let e = map_first_err(r#""#); + match e { + crate::HtmlError::UnsupportedNativeElement { tag, suggestion } => { + assert_eq!(tag, "img"); + assert_eq!(suggestion, "rm-image"); + } + other => panic!("expected UnsupportedNativeElement, got: {other:?}"), + } + } + + #[test] + fn video_element_is_refused_with_rm_video_suggestion() { + let e = map_first_err(r#""#); + match e { + crate::HtmlError::UnsupportedNativeElement { tag, suggestion } => { + assert_eq!(tag, "video"); + assert_eq!(suggestion, "rm-video"); + } + other => panic!("expected UnsupportedNativeElement, got: {other:?}"), + } + } + + #[test] + fn svg_element_is_refused_with_rm_svg_suggestion() { + let e = map_first_err(r#""#); + match e { + crate::HtmlError::UnsupportedNativeElement { tag, suggestion } => { + assert_eq!(tag, "svg"); + assert_eq!(suggestion, "rm-svg"); + } + other => panic!("expected UnsupportedNativeElement, got: {other:?}"), + } + } + + // --- boolean attributes on custom elements (constat 4) --- + + #[test] + fn custom_element_bool_attribute_true_and_false() { + let v = map_first(r#""#); + assert_eq!(v["auto_scroll"], json!(false)); + assert_eq!(v["diff"], json!(true)); + } + + #[test] + fn custom_element_bare_attribute_becomes_true() { + let v = map_first(r#""#); + assert_eq!( + v["diff"], + json!(true), + "bare boolean attribute must become true, not be dropped: {v}" + ); + } } diff --git a/crates/rustmotion-html/src/lib.rs b/crates/rustmotion-html/src/lib.rs index dcfd7eb..092bd1d 100644 --- a/crates/rustmotion-html/src/lib.rs +++ b/crates/rustmotion-html/src/lib.rs @@ -69,6 +69,34 @@ pub enum HtmlError { /// Emitted when `` sets both `path`/`src` and `source` — they are mutually exclusive. #[error(": 'path'/'src' and 'source' are mutually exclusive")] FontPathAndSourceConflict { family: String }, + /// Emitted for `

Styled by class

"##; + let err = html_to_scenario_value(html).expect_err("

Hi

"##; + let err = + html_to_scenario_value(html).expect_err("root-level