diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 65240d15..fcd122d6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,9 @@ on: env: CARGO_TERM_COLOR: always +permissions: + contents: read + jobs: fmt: runs-on: ubuntu-latest @@ -56,42 +59,48 @@ jobs: - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install cargo-audit - run: cargo install cargo-audit --locked + run: cargo install cargo-audit --version 0.22.2 --locked # Blocking on any advisory not listed below — a newly introduced # vulnerability fails this job. Every `--ignore` is a pre-existing # transitive-dependency advisory tolerated today because the fix is a # dependency version bump, and version bumps for the published # `rustmotion` crate are being handled separately from this workstream # (crates/rustmotion/Cargo.toml, orchestrator-owned). Unmaintained/ - # unsound/yanked advisories (17 as of 2026-09-22) print but do not fail + # unsound/yanked advisories (13 as of 2026-09-25) print but do not fail # the job — that's `cargo audit`'s own default, left unchanged here. # + # rustls/rustls-webpki (RUSTSEC-2026-0285, -0104, -0098, -0099, -0049) and + # crossbeam-epoch (RUSTSEC-2026-0204) were closed 2026-09-25 by a plain + # `cargo update -p ` — their consumers (ureq; rayon-core <- exr <- + # image) already accepted the patched semver range, so no Cargo.toml edit + # was needed. Only the two advisories below have no such path today: + # # Review by 2026-12-22, or sooner once the dependency bumps land: - # RUSTSEC-2025-0008 — openh264-sys2 0.6.6, heap overflow in decoding. - # Direct dependency of the published `rustmotion` crate. Fix: openh264 >=0.8.0. - # RUSTSEC-2026-0204 — crossbeam-epoch 0.9.18, invalid pointer deref in `fmt::Pointer`. - # Via rayon-core <- exr <- image, reaches rustmotion-core/-components. Fix: >=0.9.20. + # RUSTSEC-2025-0008 — openh264-sys2 0.6.6, heap overflow in the DECODING path. + # `h264.rs` only imports Encoder/YUVBuffer/OpenH264API — no decoder type is ever + # constructed, and Cargo.lock's openh264-sys2 pulls in cc/nasm-rs/walkdir, not + # libloading, so it is the source-compiled build the advisory itself calls safe at + # >=0.6.6 ("if you rely on our `source` feature only, >=0.6.6 should be safe"). + # `cargo audit` still flags it because its patched-version range (>=0.8.0) only + # covers the libloading/prebuilt-DLL path we don't use. Bumping past 0.8.0 would + # touch `h264.rs`'s API usage for no reachable fix; left ignored on that basis. # RUSTSEC-2026-0195, RUSTSEC-2026-0194 — quick-xml 0.38.4 / 0.39.4, DoS + quadratic runtime. - # 0.38.4 via syntect reaches the published crates; 0.39.4 via dioxus-desktop/rfd is - # rustmotion-studio-only (Linux/Wayland file dialogs). Fix: >=0.41.0. - # RUSTSEC-2026-0285 — rustls 0.23.37, TLS 1.3 handshake level-boundary bug. - # Via ureq, used by rustmotion/rustmotion-core for Google Fonts + Iconify fetches. Fix: >=0.23.45. - # RUSTSEC-2026-0104, RUSTSEC-2026-0098, RUSTSEC-2026-0099, RUSTSEC-2026-0049 — rustls-webpki - # 0.103.9, four CRL/name-constraint parsing bugs. Same ureq path as rustls above. - # Fix: >=0.103.13,<0.104.0-alpha.1 (or the matching 0.104 alpha per advisory). + # 0.38.4 reaches the published crates via syntect -> plist. 0.39.4 is + # rustmotion-studio-only (Linux/Wayland), via wayland-client/wayland-protocols* -> + # wayland-scanner, themselves pulled in by rfd (file dialogs) and gpui-pre-linux + # (window backend) — not dioxus-desktop, which is no longer a dependency since the + # gpui-kit migration. Fix: >=0.41.0. `cargo update -p quick-xml` has no candidate + # today — syntect/plist and wayland-scanner both pin narrower ranges than that, so + # closing this needs a Cargo.toml bump in those third-party crates, not just a lock + # update (a third, already-fixed instance is in the tree today via `xcb`). # RUSTSEC-2026-0257 — webbrowser 1.2.1, BROWSER env argument injection on Unix. - # Via dioxus-desktop, rustmotion-studio only (`publish = false`, never reaches a published - # crate). Fix: >=1.2.2. + # `webbrowser` is no longer in Cargo.lock at all (it left with dioxus-desktop during the + # gpui-kit migration) — this --ignore is currently inert. Left in rather than dropped + # silently: re-verify before removing, in case something reintroduces the crate. - name: Audit dependencies run: > cargo audit --ignore RUSTSEC-2025-0008 - --ignore RUSTSEC-2026-0204 --ignore RUSTSEC-2026-0195 --ignore RUSTSEC-2026-0194 - --ignore RUSTSEC-2026-0285 - --ignore RUSTSEC-2026-0104 - --ignore RUSTSEC-2026-0098 - --ignore RUSTSEC-2026-0099 - --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0257 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f54fd937..1dd83c9e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,11 +5,40 @@ on: tags: - "[0-9]+.[0-9]+.[0-9]+" +permissions: + contents: read + checks: read + jobs: publish: runs-on: ubuntu-latest + environment: release steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Verify tag is reachable from main + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Tag $GITHUB_REF_NAME (commit $GITHUB_SHA) is not an ancestor of origin/main; refusing to publish from a commit that was never merged." + exit 1 + fi + + - name: Verify CI passed on the tagged commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + runs=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs" \ + --jq '.check_runs[] | select(.app.slug == "github-actions") | "\(.name)=\(.conclusion)"') + echo "$runs" + for job in fmt clippy test audit; do + if ! echo "$runs" | grep -qx "${job}=success"; then + echo "::error::CI job '$job' has not succeeded on $GITHUB_SHA; refusing to publish." + exit 1 + fi + done - name: Install system dependencies # Doit rester identique à ci.yaml : l'étape « Run tests » ci-dessous lance diff --git a/Cargo.lock b/Cargo.lock index f46bbd86..c44d4541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1546,9 +1546,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -4439,9 +4439,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -6581,9 +6581,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -6627,9 +6627,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -8491,9 +8491,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" diff --git a/README.md b/README.md index c119f1d9..aaec51c3 100644 --- a/README.md +++ b/README.md @@ -21,57 +21,60 @@ cargo install rustmotion Generate and install completions for your shell: ```bash +# Or let rustmotion install completions for your current shell automatically: +rustmotion completions install + # Zsh (add to ~/.zshrc) -rustmotion completions zsh > ~/.zfunc/_rustmotion +rustmotion completions generate zsh > ~/.zfunc/_rustmotion # then add to .zshrc: fpath=(~/.zfunc $fpath) && autoload -Uz compinit && compinit # Or one-liner for Oh My Zsh: -rustmotion completions zsh > ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/rustmotion/_rustmotion +rustmotion completions generate zsh > ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/rustmotion/_rustmotion # Bash (add to ~/.bashrc) -rustmotion completions bash > ~/.local/share/bash-completion/completions/rustmotion +rustmotion completions generate bash > ~/.local/share/bash-completion/completions/rustmotion # Fish -rustmotion completions fish > ~/.config/fish/completions/rustmotion.fish +rustmotion completions generate fish > ~/.config/fish/completions/rustmotion.fish ``` ## Quick Start ```bash # Render a video -rustmotion render scenario.json -o video.mp4 +rustmotion render -f scenario.json -o video.mp4 # Render with a specific codec -rustmotion render scenario.json -o video.webm --codec vp9 --crf 30 +rustmotion render -f scenario.json -o video.webm --codec vp9 --crf 30 # Export as PNG sequence -rustmotion render scenario.json -o frames/ --format png-seq +rustmotion render -f scenario.json -o frames/ --format png-seq # Export as animated GIF -rustmotion render scenario.json -o output.gif --format gif +rustmotion render -f scenario.json -o output.gif --format gif # Render a single frame for preview -rustmotion render scenario.json --frame 42 -o frame.png +rustmotion render -f scenario.json --frame 42 -o frame.png # Validate without rendering -rustmotion validate scenario.json +rustmotion validate -f scenario.json # Export JSON Schema (for editor autocompletion or LLM prompts) rustmotion schema -o schema.json # Show scenario info -rustmotion info scenario.json +rustmotion info -f scenario.json ``` ## Claude Code Skills -rustmotion ships with built-in [Claude Code](https://claude.ai/claude-code) skills — 30 rules and best practices for generating video scenarios with AI. After installing rustmotion, run: +rustmotion ships with built-in [Claude Code](https://claude.ai/claude-code) skills — 57 rules and best practices for generating video scenarios with AI. After installing rustmotion, run: ```bash # Install skills in your video project (recommended) cd my-video-project/ rustmotion skills install -# → .claude/skills/rustmotion/ (SKILL.md + 29 rules) +# → .claude/skills/rustmotion/ (SKILL.md + 57 rules) # → CLAUDE.md (project instructions) # Or install globally (available in all projects) @@ -190,7 +193,7 @@ rustmotion schema -o schema.json Shows information about a scenario (duration, scene count, dimensions, ...). ```bash -rustmotion info scenario.json +rustmotion info -f scenario.json ``` ### `rustmotion skills` @@ -235,7 +238,7 @@ Generates or installs shell completions — `install`, `uninstall`, `generate ": {...} }` form — set it explicitly either way | Animation speed | | `gradient_type` | `string` | `"linear"` | `"linear"` or `"radial"` | -| `preset` | `string` | | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"grid_lines"`, `"halo"`, `"pixel_grid"`, `"heropattern"` | | `element_size` | `f32` | `4.0` | Dot size for grid_dots; stroke width for concentric_circles | | `spacing` | `f32` | `60.0` | Element spacing for grid_dots/concentric_circles | | `count` | `u32` | | Number of circles for concentric_circles (overrides spacing) | @@ -1958,7 +1977,7 @@ Any component can be rendered with true 3D perspective using keyframe animations ```json { "style": { - "box-shadow": { "color": "#00000060", "offset_x": 0, "offset_y": 20, "blur": 60 }, + "box-shadow": [{ "color": "#00000060", "offset-x": 0, "offset-y": 20, "blur": 60 }], "animation": [{ "name": "keyframes", "keyframes": [ @@ -1980,16 +1999,16 @@ Define sequential animation phases within a single scene using the `timeline` fi ```json { "style": { - "animation": [{ "name": "fade_in_up", "duration": 0.6 }], - "timeline": [ - { "at": 2.0, "animation": [{ "name": "shake", "duration": 0.5 }] }, - { "at": 4.0, "animation": [{ "name": "fade_out", "duration": 0.8 }] } - ] - } + "animation": [{ "name": "fade_in_up", "duration": 0.6 }] + }, + "timeline": [ + { "at": 2.0, "animation": [{ "name": "shake", "duration": 0.5 }] }, + { "at": 4.0, "animation": [{ "name": "fade_out", "duration": 0.8 }] } + ] } ``` -Each step activates at `step.at` seconds, with animations resolved relative to that time. Steps merge additively with base animations. +Each step activates at `step.at` seconds, with animations resolved relative to that time. Steps merge additively with base animations. `timeline` is a root field on the component, not nested under `style` (`CssStyle` has no `timeline` key). ### Motion Blur @@ -2011,14 +2030,14 @@ Renders multiple sub-frames and composites them for physically-correct motion bl | Format | Command | Requires | |---|---|---| -| **MP4 (H.264 10-bit)** | `rustmotion render in.json -o out.mp4` | ffmpeg (auto-detected) | -| **MP4 (H.264 8-bit)** | `rustmotion render in.json -o out.mp4` | Built-in (fallback without ffmpeg) | -| **MP4 (H.265)** | `rustmotion render in.json -o out.mp4 --codec h265` | ffmpeg | -| **WebM (VP9)** | `rustmotion render in.json -o out.webm --codec vp9` | ffmpeg | -| **MOV (ProRes)** | `rustmotion render in.json -o out.mov --codec prores` | ffmpeg | -| **Animated GIF** | `rustmotion render in.json -o out.gif --format gif` | Built-in | -| **PNG Sequence** | `rustmotion render in.json -o frames/ --format png-seq` | Built-in | -| **Single Frame** | `rustmotion render in.json --frame 0 -o preview.png` | Built-in | +| **MP4 (H.264 10-bit)** | `rustmotion render -f in.json -o out.mp4` | ffmpeg (auto-detected) | +| **MP4 (H.264 8-bit)** | `rustmotion render -f in.json -o out.mp4` | Built-in (fallback without ffmpeg) | +| **MP4 (H.265)** | `rustmotion render -f in.json -o out.mp4 --codec h265` | ffmpeg | +| **WebM (VP9)** | `rustmotion render -f in.json -o out.webm --codec vp9` | ffmpeg | +| **MOV (ProRes)** | `rustmotion render -f in.json -o out.mov --codec prores` | ffmpeg | +| **Animated GIF** | `rustmotion render -f in.json -o out.gif --format gif` | Built-in | +| **PNG Sequence** | `rustmotion render -f in.json -o frames/ --format png-seq` | Built-in | +| **Single Frame** | `rustmotion render -f in.json --frame 0 -o preview.png` | Built-in | Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), and ProRes 4444. @@ -2045,13 +2064,14 @@ Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), an { "type": "shape", "shape": "rounded_rect", - "size": { "width": 900, "height": 520 }, + "fill": { + "type": "linear", + "colors": ["#6366f1", "#8b5cf6"], + "angle": 135 + }, "style": { - "fill": { - "type": "linear", - "colors": ["#6366f1", "#8b5cf6"], - "angle": 135 - }, + "width": 900, + "height": 520, "border-radius": 32, "animation": [{ "name": "scale_in", "duration": 0.6 }] } @@ -2059,8 +2079,9 @@ Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), an { "type": "icon", "icon": "lucide:rocket", - "size": { "width": 80, "height": 80 }, "style": { + "width": 80, + "height": 80, "color": "#FFFFFF", "animation": [{ "name": "fade_in_up", "delay": 0.3, "duration": 0.6 }] } @@ -2137,7 +2158,7 @@ pub trait Painter { } ``` -`PaintCtx` carries `time`, `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. +`PaintCtx` carries `time`, `scenario_time` (elapsed since the scenario/view started — what audio-reactive painters index on, since `time` resets at each scene), `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. ### Workspace layout @@ -2147,7 +2168,7 @@ crates/ │ ├── css/ # CssStyle, units, cascade, taffy bridge, animation resolution │ ├── engine/ # box_tree, layout_pass, paint_pass, animator, transitions, Skia primitives │ ├── schema/ # Scenario, Scene, VideoConfig, style, background, animation, codeblock models -│ └── traits/ # Painter, Animatable, Timed, Styled +│ └── traits/ # Painter, Animatable, Timed, Styled, + 7 more (11 files total) ├── rustmotion-components/src/ │ ├── lib.rs # `Component` enum (60 variants) + dispatch (as_painter, as_animatable, ...) │ ├── box_builder.rs # JSON components → BuiltScene (box tree + stagger delays) diff --git a/crates/rustmotion-components/src/audio_spectrum.rs b/crates/rustmotion-components/src/audio_spectrum.rs index 6e361858..a389b5bc 100644 --- a/crates/rustmotion-components/src/audio_spectrum.rs +++ b/crates/rustmotion-components/src/audio_spectrum.rs @@ -115,18 +115,25 @@ impl Painter for AudioSpectrum { let (r, g, b, a) = parse_hex_color(&self.color); let color = Color::from_argb(a, r, g, b); + canvas.save(); + canvas.clip_rect( + Rect::from_xywh(0.0, 0.0, w.max(0.0), h.max(0.0)), + skia_safe::ClipOp::Intersect, + true, + ); + match self.mode { SpectrumMode::Bars => { - let total_gap = self.bar_gap * (n as f32 - 1.0); - let bar_w = ((w - total_gap) / n as f32).max(1.0); + let stride = w / n as f32; + let bar_w = (stride - self.bar_gap).max(1.0); let mut paint = Paint::default(); paint.set_color(color); paint.set_style(PaintStyle::Fill); paint.set_anti_alias(true); for (i, &v) in values.iter().enumerate() { - let bar_h = (v * h).max(self.min_height); - let x = i as f32 * (bar_w + self.bar_gap); + let bar_h = (v * h).max(self.min_height).min(h); + let x = i as f32 * stride; let y = h - bar_h; canvas.draw_rect(Rect::from_xywh(x, y, bar_w, bar_h), &paint); } @@ -145,7 +152,9 @@ impl Painter for AudioSpectrum { for (i, &v) in values.iter().enumerate() { let angle = (i as f32 / n as f32) * 2.0 * std::f32::consts::PI; - let bar_len = (v * (max_r - inner_r)).max(self.min_height); + let bar_len = (v * (max_r - inner_r)) + .max(self.min_height) + .min(max_r - inner_r); let x0 = cx + inner_r * angle.cos(); let y0 = cy + inner_r * angle.sin(); let x1 = cx + (inner_r + bar_len) * angle.cos(); @@ -154,5 +163,112 @@ impl Painter for AudioSpectrum { } } } + + canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ink_bounds(surface: &mut skia_safe::Surface, w: i32, h: i32) -> (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 miny, mut maxx, mut maxy) = (w, h, 0, 0); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + minx = minx.min(x); + miny = miny.min(y); + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + (minx, miny, maxx, maxy) + } + + #[test] + fn bars_overflowing_the_box_are_kept_inside_it() { + const CANVAS_W: i32 = 800; + const CANVAS_H: i32 = 700; + const OFFSET_X: f32 = 200.0; + const OFFSET_Y: f32 = 500.0; + const BOX_W: f32 = 300.0; + const BOX_H: f32 = 100.0; + + let spectrum = AudioSpectrum { + track: None, + bars: 64, + mode: SpectrumMode::Bars, + color: "#FFFFFF".to_string(), + bar_gap: 8.0, + min_height: 500.0, + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + }; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((CANVAS_W, CANVAS_H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.translate((OFFSET_X, OFFSET_Y)); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: BOX_W, + height: BOX_H, + ..Default::default() + }; + let ctx = PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: CANVAS_W as u32, + video_height: CANVAS_H as u32, + stagger_offset: 0.0, + }; + spectrum.paint_content(canvas, &layout, &AnimatedProperties::default(), &ctx); + } + + let (minx, miny, maxx, maxy) = ink_bounds(&mut surface, CANVAS_W, CANVAS_H); + + assert!( + minx as f32 >= OFFSET_X, + "ink must not start left of the box (x={minx}, box left={OFFSET_X})" + ); + assert!( + maxx as f32 <= OFFSET_X + BOX_W, + "ink must not run right of the box (x={maxx}, box right={})", + OFFSET_X + BOX_W + ); + assert!( + miny as f32 >= OFFSET_Y, + "min_height must not push bars above the box's top edge (y={miny}, box top={OFFSET_Y})" + ); + assert!( + maxy as f32 <= OFFSET_Y + BOX_H, + "ink must not run below the box (y={maxy}, box bottom={})", + OFFSET_Y + BOX_H + ); } } diff --git a/crates/rustmotion-components/src/avatar_group.rs b/crates/rustmotion-components/src/avatar_group.rs index aa8d02a2..15a30aa6 100644 --- a/crates/rustmotion-components/src/avatar_group.rs +++ b/crates/rustmotion-components/src/avatar_group.rs @@ -65,6 +65,19 @@ rustmotion_core::impl_traits!(AvatarGroup { }); impl AvatarGroup { + fn natural_size(&self) -> (f32, f32) { + let visible = self.visible_count() as f32; + let extra = if self.overflow_count() > 0 { 1.0 } else { 0.0 }; + let total = visible + extra; + let step = (self.size - self.overlap).max(0.0); + let w = if total <= 0.0 { + 0.0 + } else { + self.size + (total - 1.0) * step + }; + (w.max(1.0), self.size.max(1.0)) + } + pub fn visible_count(&self) -> usize { match self.max_display { Some(max) => max.min(self.avatars.len()), @@ -200,10 +213,104 @@ impl Painter for AvatarGroup { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, _ctx: &PaintCtx, ) { + let (natural_w, natural_h) = self.natural_size(); + let scale_x = layout.width / natural_w; + let scale_y = layout.height / natural_h; + canvas.save(); + canvas.scale((scale_x, scale_y)); let _ = self.paint(canvas); + canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(size: f32, overlap: f32, count: usize, max_display: Option) -> AvatarGroup { + AvatarGroup { + avatars: (0..count) + .map(|_| AvatarGroupItem { + src: "/nonexistent.png".to_string(), + }) + .collect(), + max_display, + size, + overlap, + border_width: default_border_width(), + border_color: default_border_color(), + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + #[test] + fn paint_content_scales_to_the_layout_box_not_its_own_size_field() { + let g = group(48.0, 16.0, 6, Some(0)); + assert!(g.overflow_count() > 0, "fixture must trigger the +N path"); + const W: i32 = 200; + const H: i32 = 100; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 60.0, + height: 30.0, + ..Default::default() + }; + g.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }, + ); + } + 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 maxx = 0; + let mut maxy = 0; + for y in 0..H { + for x in 0..W { + if buf[((y * W + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + assert!( + maxx < 60 && maxy < 30, + "expected ink within the 60x30 layout box, got ink up to ({maxx}, {maxy})" + ); } } diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs index f4f2cdbb..0165ca4d 100644 --- a/crates/rustmotion-components/src/badge.rs +++ b/crates/rustmotion-components/src/badge.rs @@ -2,13 +2,12 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, RRect, Rect}; -use rustmotion_core::css::style::AlignSelf; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, - measure_text_with_fallback, paint_from_hex, typeface_with_fallback, + measure_text_with_fallback, paint_from_hex, sandboxed_svg_options, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -66,17 +65,7 @@ pub struct Badge { pub count: Option, #[serde(flatten)] pub timing: TimingConfig, - /// `align-self` defaults to `flex-start` (not the flex container's - /// `stretch`) — a badge is an atomic icon+label chip that must keep its - /// natural intrinsic width even inside a `flex`/`card` column; without - /// this, the default cross-axis `stretch` wins over `BadgeIntrinsic` - /// and the chip becomes a full-width bar. An author-specified - /// `align-self` in JSON is always respected (this only fills the gap - /// when it's absent). - #[serde( - default = "default_badge_style", - deserialize_with = "deserialize_no_stretch_style" - )] + #[serde(default)] pub style: CssStyle, #[serde(default)] pub timeline: Vec, @@ -84,27 +73,6 @@ pub struct Badge { pub stagger: Option, } -fn default_badge_style() -> CssStyle { - CssStyle { - align_self: Some(AlignSelf::FlexStart), - ..CssStyle::default() - } -} - -/// Deserializes `style` normally, then defaults `align-self` to -/// `flex-start` when the author didn't set it explicitly — see the doc -/// comment on [`Badge::style`]. -fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let mut style = CssStyle::deserialize(deserializer)?; - if style.align_self.is_none() { - style.align_self = Some(AlignSelf::FlexStart); - } - Ok(style) -} - rustmotion_core::impl_traits!(Badge { Animatable => animation, Timed => timing, @@ -189,7 +157,7 @@ impl Badge { let img = if let Some(cached) = cache.get(&cache_key) { cached.clone() } else if let Ok(svg_data) = fetch_icon_svg(icon_id, icon_color, icon_w, icon_h) { - let opt = usvg::Options::default(); + let opt = sandboxed_svg_options(); if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) { let svg_size = tree.size(); if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) { @@ -362,29 +330,18 @@ impl Painter for Badge { #[cfg(test)] mod tests { use super::*; + use rustmotion_core::css::style::AlignSelf; fn parse(json: &str) -> Badge { serde_json::from_str(json).expect("badge should deserialize") } #[test] - fn style_defaults_to_flex_start_when_absent() { - // #127: `align-items: stretch` (the flex column default) was - // winning over `BadgeIntrinsic`, stretching every badge in a card - // to the container's full width. No `style` key at all in the - // JSON is the common case — this must still default away from - // stretch. + fn style_align_self_is_not_set_by_parsing_alone() { let badge = parse(r#"{"type":"badge","text":"v1"}"#); - assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart)); - } - - #[test] - fn style_defaults_to_flex_start_with_other_style_keys_present() { - // Same fix, but exercised through the `deserialize_with` path - // (some `style` object present, just not `align-self`) rather - // than the field-level `default` path (`style` entirely absent). + assert_eq!(badge.style.align_self, None); let badge = parse(r##"{"type":"badge","text":"v1","style":{"background":"#f00"}}"##); - assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart)); + assert_eq!(badge.style.align_self, None); assert_eq!(badge.style.background_color_str(), Some("#f00")); } diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index f5ab158b..b4a386c7 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize}; +use rustmotion_core::css::style::{AlignItems, AlignSelf, CssStyle, Position, Size as CSize}; use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP}; use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId}; @@ -61,10 +61,12 @@ pub struct BuiltScene<'a> { /// Lookup table — `components[id as usize]` is the component for `id`. /// `None` for synthetic boxes (the root scene wrapper). pub components: Vec>, - /// Per-node animation delay accumulated from ancestor containers' - /// `stagger` (indexed like `components`). Consumed by the paint - /// dispatcher so internal animations shift by the same amount as the - /// CSS overrides resolved at build time. + /// Per-node animation delay: ancestor containers' `stagger` plus the + /// node's own `start_at` (indexed like `components`). Consumed by the + /// paint dispatcher so internal animations shift by the same amount as + /// the CSS overrides resolved at build time — and so an entrance/exit + /// animation on a `start_at`ed node plays from its own first keyframe + /// instead of one already resolved at the untouched scene clock. pub stagger_delays: Vec, /// Per-node affine time remap accumulated from ancestor containers' /// `time_scale`/`time_offset`. Entry `i` is `(scale, shift)` where @@ -150,6 +152,7 @@ where 0.0, (1.0, 0.0), &root_css, + viewport, )); } @@ -231,10 +234,12 @@ fn build_ghosts<'a>( time_params: &mut Vec<(f64, f64)>, next_id: &mut NodeId, actx: BuildAnimationCtx, - stagger_delay: f64, + extra_delay: f64, time_remap: (f64, f64), effects: &[AnimationEffect], parent_css: &CssStyle, + window: Option, + viewport: (f32, f32), ) -> Vec { let (mb, tr) = detect_ghost_effects(effects); @@ -286,12 +291,13 @@ fn build_ghosts<'a>( // 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_intrinsic_overrides(&child.component, &mut css, parent_css); // Apply timeline style states at the ghost time. if let Some(animatable) = child.component.as_animatable() { let steps = animatable.timeline_steps(); if steps.iter().any(|s| s.style.is_some()) { let skip_opacity = css.transition.is_some(); - apply_style_states(&mut css, steps, ghost_time - stagger_delay, skip_opacity); + apply_style_states(&mut css, steps, ghost_time - extra_delay, skip_opacity); // Same `border-radius`/`background` smoothing as the // principal path in `build_child`, sampled at `ghost_time` // so a motion-blur/trail ghost mid-transition matches what @@ -299,7 +305,7 @@ fn build_ghosts<'a>( let overrides = resolve_transition_css_overrides( child.component.as_styled().style_config(), steps, - ghost_time - stagger_delay, + ghost_time - extra_delay, ); if let Some(br) = overrides.border_radius { css.border_radius = Some(br); @@ -316,7 +322,7 @@ fn build_ghosts<'a>( scene_duration: actx.scene_duration, fps: actx.fps, }; - if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay) { + if let Some(ghost_effects) = effective_effects(&child.component, extra_delay, ghost_time) { let props = resolve_props_for_effects( &ghost_effects, ghost_actx.time, @@ -352,17 +358,18 @@ fn build_ghosts<'a>( *next_id += 1; // Register a slot so the dispatcher can look up the component. components.push(Some(child)); - stagger_delays.push(stagger_delay); + stagger_delays.push(extra_delay); time_params.push(time_remap); + let ghost_intrinsic = component_intrinsic(&child.component, &ghost_css, viewport); ghosts.push(BoxNode { id: ghost_id, kind: BoxKind::Ghost(Arc::new(ghost_id)), css: ghost_css, children: Vec::new(), // v1: no child recursion in ghosts - intrinsic: None, // v1: ghosts have no layout-measured content + intrinsic: ghost_intrinsic, source_path: None, - window: None, + window, }); } } @@ -384,17 +391,18 @@ fn build_ghosts<'a>( let ghost_id = *next_id; *next_id += 1; components.push(Some(child)); - stagger_delays.push(stagger_delay); + stagger_delays.push(extra_delay); time_params.push(time_remap); + let ghost_intrinsic = component_intrinsic(&child.component, &ghost_css, viewport); trail_nodes.push(BoxNode { id: ghost_id, kind: BoxKind::Ghost(Arc::new(ghost_id)), css: ghost_css, children: Vec::new(), - intrinsic: None, + intrinsic: ghost_intrinsic, source_path: None, - window: None, + window, }); } // Oldest ghost (most-trailing) painted first → prepend in reverse. @@ -429,6 +437,7 @@ fn build_child<'a>( stagger_delay: f64, time_remap: (f64, f64), parent_css: &CssStyle, + viewport: (f32, f32), ) -> Vec { // Compute the local animation context for this node — remapped by the // accumulated affine time transform from ancestor containers. @@ -447,12 +456,52 @@ fn build_child<'a>( } }); + // A node's own `start_at` rebases its animation clock the same way an + // ancestor's `stagger` already does: both push out the instant the + // component's *own* first keyframe is considered reached. Without this, + // `start_at` only gated visibility — the effect list still resolved + // against the untouched scene clock, so an entrance already playing out + // by the time the node became visible snapped straight to its end state, + // and an exit whose own `delay` elapsed before `start_at` left the node + // painting nothing for its whole visible window. + let anim_delay = stagger_delay + + child + .component + .as_timed() + .and_then(|t| t.timing().0) + .unwrap_or(0.0); + + // Visibility window (start_at/end_at) — enforced by the paint pass. + // The stagger delay shifts the window too, so a hard-cut child appears + // in step with its staggered siblings. + // When there is an accumulated time remap, the window times (which are in + // local/remapped time) must be converted back to global time so the paint + // pass (which operates on global time) can apply them correctly. + // If `t_local = scale * t_global + shift`, then `t_global = (t_local - shift) / scale`. + let window = child.component.as_timed().and_then(|t| { + let (start, end) = t.timing(); + (start.is_some() || end.is_some()).then_some({ + let (scale, shift) = time_remap; + let to_global = |t_local: f64| -> f64 { + if scale.abs() < 1e-10 { + t_local + } else { + (t_local - shift) / scale + } + }; + rustmotion_core::engine::box_tree::PaintWindow { + start: start.map(|s| to_global(s + stagger_delay)), + end: end.map(|e| to_global(e + stagger_delay)), + } + }) + }); + // ── Ghost generation (motion_blur / trail) ─────────────────────────────── // Must happen before allocating the principal's id so that ghost ids are // lower (earlier in the slot table). The principal's id is allocated below. let mut ghosts: Vec = Vec::new(); if let Some(actx) = local_actx { - if let Some(effects) = effective_effects(&child.component, stagger_delay) { + if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) { ghosts = build_ghosts( child, components, @@ -460,10 +509,12 @@ fn build_child<'a>( time_params, next_id, actx, - stagger_delay, + anim_delay, time_remap, &effects, parent_css, + window, + viewport, ); } } @@ -471,7 +522,7 @@ fn build_child<'a>( let id = *next_id; *next_id += 1; components.push(Some(child)); - stagger_delays.push(stagger_delay); + stagger_delays.push(anim_delay); time_params.push(time_remap); let mut css = component_css(&child.component); @@ -494,6 +545,7 @@ fn build_child<'a>( // `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); + apply_intrinsic_overrides(&child.component, &mut css, parent_css); // Timeline style states: merge every state whose (at + stagger) <= t // into the box CSS. Opacity is excluded when a `transition` smooths it @@ -506,7 +558,7 @@ fn build_child<'a>( if steps.iter().any(|s| s.style.is_some()) { let t = local_actx.map(|a| a.time).unwrap_or(0.0); let skip_opacity = css.transition.is_some(); - apply_style_states(&mut css, steps, t - stagger_delay, skip_opacity); + apply_style_states(&mut css, steps, t - anim_delay, skip_opacity); // `border-radius`/`background` (solid colour, uniform absolute // px only — see `resolve_transition_css_overrides`'s doc // comment) smooth the same way opacity does above, but land @@ -516,7 +568,7 @@ fn build_child<'a>( let overrides = resolve_transition_css_overrides( child.component.as_styled().style_config(), steps, - t - stagger_delay, + t - anim_delay, ); if let Some(br) = overrides.border_radius { css.border_radius = Some(br); @@ -534,7 +586,7 @@ fn build_child<'a>( // — internal animations like draw_progress or char_animation remain on the // `AnimatedProperties` legacy path. if let Some(actx) = local_actx { - if let Some(effects) = effective_effects(&child.component, stagger_delay) { + if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) { let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); if props_has_paint_overrides(&props) { apply_animated_props(&mut css, &props); @@ -599,31 +651,6 @@ fn build_child<'a>( } } - // Visibility window (start_at/end_at) — enforced by the paint pass. - // The stagger delay shifts the window too, so a hard-cut child appears - // in step with its staggered siblings. - // When there is an accumulated time remap, the window times (which are in - // local/remapped time) must be converted back to global time so the paint - // pass (which operates on global time) can apply them correctly. - // If `t_local = scale * t_global + shift`, then `t_global = (t_local - shift) / scale`. - let window = child.component.as_timed().and_then(|t| { - let (start, end) = t.timing(); - (start.is_some() || end.is_some()).then_some({ - let (scale, shift) = time_remap; - let to_global = |t_local: f64| -> f64 { - if scale.abs() < 1e-10 { - t_local - } else { - (t_local - shift) / scale - } - }; - rustmotion_core::engine::box_tree::PaintWindow { - start: start.map(|s| to_global(s + stagger_delay)), - end: end.map(|e| to_global(e + stagger_delay)), - } - }) - }); - let children_boxes = container_children( &child.component, components, @@ -635,8 +662,9 @@ fn build_child<'a>( stagger_delay, time_remap, &css, + viewport, ); - let intrinsic = component_intrinsic(&child.component, &css); + let intrinsic = component_intrinsic(&child.component, &css, viewport); let principal = BoxNode { id, @@ -655,13 +683,21 @@ fn build_child<'a>( } /// The full effect list for a component at paint time: `style.animation`, -/// plus `timeline` steps shifted by their `at`, plus keyframes synthesized -/// from timeline style-state changes (`style.transition`), plus the -/// container-stagger delay applied to everything. Returns `None` when there -/// is nothing to resolve, `Some(Cow::Borrowed)` on the no-merge fast path. +/// plus the `timeline` steps whose `at` `t` has reached, shifted by their +/// `at`, plus keyframes synthesized from timeline style-state changes +/// (`style.transition`), plus `extra_delay` applied to everything — callers +/// fold in both the ancestor-stagger delay and the node's own `start_at` here, +/// so the effect list is agnostic to which one (or both) it's carrying. +/// Returns `None` when there is nothing to resolve, `Some(Cow::Borrowed)` on +/// the no-merge fast path. +/// +/// `t` is the component's own local time, the same clock +/// `resolve_props_for_effects` is called with, and the same one +/// `apply_style_states` gates a step's `style` on. pub fn effective_effects( component: &Component, extra_delay: f64, + t: f64, ) -> Option> { let animatable = component.as_animatable()?; let effects = animatable.animation_effects(); @@ -675,7 +711,7 @@ pub fn effective_effects( return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects)); } let mut merged = effects.to_vec(); - for step in steps { + for step in steps.iter().filter(|s| s.at <= t - extra_delay) { for effect in &step.animation { let mut e = effect.clone(); e.shift_delay(step.at); @@ -1143,39 +1179,48 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani fn component_intrinsic( component: &Component, cascaded_css: &CssStyle, + viewport: (f32, f32), ) -> Option> { let cascaded_component = component.with_cascaded_style(cascaded_css); let component = cascaded_component.as_ref().unwrap_or(component); use Component::*; match component { - Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))), + Text(t) => Some(Arc::new( + crate::intrinsic::TextIntrinsic::from_text_for_viewport(t, viewport), + )), GradientText(t) => Some(Arc::new( - crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t), + crate::intrinsic::GradientTextIntrinsic::from_gradient_text_for_viewport(t, viewport), + )), + Caption(c) => Some(Arc::new( + crate::intrinsic::CaptionIntrinsic::from_caption_for_viewport(c, viewport), + )), + Kbd(k) => Some(Arc::new( + crate::intrinsic::KbdIntrinsic::from_kbd_for_viewport(k, viewport), + )), + Counter(c) => Some(Arc::new( + crate::intrinsic::CounterIntrinsic::from_counter_for_viewport(c, viewport), )), - Caption(c) => Some(Arc::new(crate::intrinsic::CaptionIntrinsic::from_caption( - c, - ))), - Kbd(k) => Some(Arc::new(crate::intrinsic::KbdIntrinsic::from_kbd(k))), - Counter(c) => Some(Arc::new(crate::intrinsic::CounterIntrinsic::from_counter( - c, - ))), NumberWheel(w) => Some(Arc::new( - crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w), + crate::intrinsic::NumberWheelIntrinsic::from_number_wheel_for_viewport(w, viewport), + )), + Badge(b) => Some(Arc::new( + crate::intrinsic::BadgeIntrinsic::from_badge_for_viewport(b, viewport), )), - Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))), Terminal(t) => Some(Arc::new( - crate::intrinsic::TerminalIntrinsic::from_terminal(t), + crate::intrinsic::TerminalIntrinsic::from_terminal_for_viewport(t, viewport), + )), + Table(t) => Some(Arc::new( + crate::intrinsic::TableIntrinsic::from_table_for_viewport(t, viewport), )), - Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))), Codeblock(c) => Some(Arc::new( - crate::intrinsic::CodeblockIntrinsic::from_codeblock(c), + crate::intrinsic::CodeblockIntrinsic::from_codeblock_for_viewport(c, viewport), )), // M2: rich_text had no intrinsic measurer at all, so it laid out // 0×0 and rendered nothing unless the author guessed an explicit // width/height. RichText(rt) => Some(Arc::new( - crate::intrinsic::RichTextIntrinsic::from_rich_text(rt), + crate::intrinsic::RichTextIntrinsic::from_rich_text_for_viewport(rt, viewport), )), _ => None, } @@ -1199,6 +1244,7 @@ fn container_children<'a>( inherited_delay: f64, time_remap: (f64, f64), parent_css: &CssStyle, + viewport: (f32, f32), ) -> Vec { let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option, f64, f64) = match component { @@ -1265,17 +1311,16 @@ fn container_children<'a>( inherited_delay + j as f64 * step, child_remap, parent_css, + viewport, )); } result } -/// Pull the component's `CssStyle`, augmented with intrinsic `width`/`height` -/// for components that carry a fixed size. +/// Pull the component's own `CssStyle`, pre-cascade and pre-intrinsic-overrides. fn component_css(component: &Component) -> CssStyle { let mut css = component_style(component).clone(); apply_default_display(component, &mut css); - apply_intrinsic_overrides(component, &mut css); css } @@ -1320,9 +1365,16 @@ fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) /// Apply per-component CSS overrides for things that the legacy /// `Widget::measure` derived from constraints (e.g. divider stretching to its /// parent, line bounding box from its endpoints). -fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { +fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle, parent_css: &CssStyle) { use Component::*; match component { + Badge(_) => { + if css.align_self.is_none() + && matches!(parent_css.align_items, None | Some(AlignItems::Stretch)) + { + css.align_self = Some(AlignSelf::FlexStart); + } + } Text(t) => { // M1: `white-space: nowrap|pre` must style the *content*, not // silently resize the *box*. Without this, CSS's "automatic @@ -1508,9 +1560,8 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { css.width = Some(CSize::Length(CLP::Px(c.width))); } if css.height.is_none() { - let font_size = c - .style - .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0); + let font_size = + css.font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0); let line_height = font_size * 1.3; let n = c.items.len() as f32; let h = n * line_height + (n - 1.0).max(0.0) * c.gap; @@ -1655,10 +1706,9 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // box is fit exactly to the unwrapped text width, the painter's // own `wrap_text(text, font, Some(text_area_w))` never has a // reason to wrap, so painted output matches this box exactly. - let font_size = t - .style - .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0); - let family = t.style.font_family_or("Inter"); + let font_size = + css.font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0); + let family = css.font_family_or("Inter"); let text_w = measure_text_line_width(&t.text, font_size, family, false); let h_pad = 12.0; // callout.rs's own `let padding = 12.0;` let v_pad = 16.0; // breathing room around the line, same order of magnitude as h_pad @@ -1677,11 +1727,11 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // Same shape as Callout above; padding value borrowed from // callout.rs since tooltip.rs's own paint() centers text in the // body with no defined constant of its own. - let font_size = t.style.font_size_px_ctx( + let font_size = css.font_size_px_ctx( &crate::intrinsic::measure_time_font_size_ctx(0.0), t.font_size, ); - let family = t.style.font_family_or("Inter"); + let family = css.font_family_or("Inter"); let text_w = measure_text_line_width(&t.text, font_size, family, false); let h_pad = 12.0; let v_pad = 16.0; @@ -1705,10 +1755,9 @@ 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. - let font_size = p - .style - .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 14.0); - let family = p.style.font_family_or("Inter"); + let font_size = + css.font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 14.0); + let family = css.font_family_or("Inter"); let h_pad = font_size * 1.2; let n = p.items.len() as f32; let labels_w: f32 = p @@ -1735,7 +1784,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { // height ratio both of those same real usages share: // `font_size: 24` paired with `style.height: 48`, i.e. // `2 × font_size`. - let font_size = m.style.font_size_px_ctx( + let font_size = css.font_size_px_ctx( &crate::intrinsic::measure_time_font_size_ctx(0.0), m.font_size, ); @@ -2185,6 +2234,169 @@ pub fn component_kind(c: &Component) -> &'static str { #[cfg(test)] mod tests { use super::*; + + /// Two timeline steps on one node. Each step is documented to trigger at + /// its own `at`, and `apply_style_states` already gates a step's `style` + /// that way — its `animation` half must obey the same rule, or a step + /// that has not begun still sets the value through the shared + /// last-effect-wins bucket. + #[test] + fn a_timeline_step_leaves_the_value_alone_until_its_at() { + let component: Component = serde_json::from_value(json!({ + "type": "shape", + "shape": "circle", + "fill": "#1EA2C2", + "style": { "width": 120, "height": 120 }, + "timeline": [ + { "at": 3.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [ + { "property": "translate_x", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 200.0 }] }] }] }, + { "at": 6.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [ + { "property": "translate_x", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 200.0 }, { "time": 1.0, "value": 0.0 }] }] }] } + ] + })) + .expect("component deserializes"); + + let tx = |t: f64| match effective_effects(&component, 0.0, t) { + Some(effects) => resolve_props_for_effects(&effects, t, 9.0).translate_x as f64, + None => AnimatedProperties::default().translate_x as f64, + }; + + assert!( + tx(0.5).abs() < 1.0, + "before either step, translate_x is 0, got {}", + tx(0.5) + ); + assert!( + (tx(3.5) - 100.0).abs() < 2.0, + "halfway through step one, got {}", + tx(3.5) + ); + assert!( + (tx(5.0) - 200.0).abs() < 1.0, + "step one has ended and holds, got {}", + tx(5.0) + ); + assert!( + (tx(6.5) - 100.0).abs() < 2.0, + "halfway through step two, got {}", + tx(6.5) + ); + assert!( + tx(8.0).abs() < 1.0, + "step two has ended and holds, got {}", + tx(8.0) + ); + } + + /// A `start_at`ed entrance must play from its own first keyframe, not + /// from wherever the unrebased scene clock already landed it. Measured + /// bug: a `fade_in_down` (0.6s) on a `start_at: 2.0` node resolved at + /// t=2.0 (the instant it becomes visible) to the animation's value at + /// scene time 2.0 — long past the 0.6s duration — so it appeared already + /// fully faded in instead of animating. + #[test] + fn start_at_rebases_the_entrance_animation_clock() { + let scene = vec![ChildComponent { + component: serde_json::from_value(json!({ + "type": "shape", + "shape": "rect", + "fill": "#1EA2C2", + "start_at": 2.0, + "style": { + "width": 120, "height": 120, + "animation": [{ "name": "fade_in_down", "duration": 0.6 }] + } + })) + .expect("component deserializes"), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let opacity_at = |t: f64| -> f32 { + let built = build_scene_at_time( + &scene, + (400.0, 400.0), + default_root_css((400.0, 400.0)), + BuildAnimationCtx { + time: t, + scenario_time: t, + scene_duration: 6.0, + fps: 30, + }, + ); + built.root.children[0].css.opacity.unwrap_or(1.0) + }; + + assert!( + opacity_at(2.0) < 0.3, + "at start_at (2.0) the fade_in_down entrance should just be beginning, got opacity {}", + opacity_at(2.0) + ); + assert!( + opacity_at(2.6) > 0.9, + "0.6s after start_at (the entrance's own duration) it should have finished, got opacity {}", + opacity_at(2.6) + ); + } + + /// Companion to the entrance case above, mirroring the measured `badge` + /// bug: an exit declared after the entrance (so it alone owns `opacity` + /// under last-declared-wins) carries its own `delay`. Unrebased, that + /// delay is measured from scene time zero, so the exit can finish before + /// `start_at` is even reached — the component then renders zero pixels + /// for its entire visible window. + #[test] + fn start_at_rebases_an_exit_animation_declared_after_the_entrance() { + let scene = vec![ChildComponent { + component: serde_json::from_value(json!({ + "type": "shape", + "shape": "rect", + "fill": "#1EA2C2", + "start_at": 2.0, + "style": { + "width": 120, "height": 120, + "animation": [ + { "name": "fade_in_down", "duration": 0.6 }, + { "name": "fade_out_up", "delay": 0.85, "duration": 0.3 } + ] + } + })) + .expect("component deserializes"), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let opacity_at = |t: f64| -> f32 { + let built = build_scene_at_time( + &scene, + (400.0, 400.0), + default_root_css((400.0, 400.0)), + BuildAnimationCtx { + time: t, + scenario_time: t, + scene_duration: 6.0, + fps: 30, + }, + ); + built.root.children[0].css.opacity.unwrap_or(1.0) + }; + + assert!( + opacity_at(2.5) > 0.5, + "the exit's own delay (0.85s) has not elapsed since start_at (2.0), the node should \ + still be visible, got opacity {}", + opacity_at(2.5) + ); + } + use rustmotion_core::css::style::{ CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize, }; @@ -2261,6 +2473,135 @@ mod tests { assert_eq!(built.components.len(), 1); // synthetic root slot } + #[test] + fn trail_ghost_gets_a_real_intrinsic_size_on_a_text_component() { + let style = CssStyle { + animation: vec![AnimationEffect::Trail(TrailConfig { + copies: 2, + spacing: 0.05, + falloff: 0.6, + })], + ..Default::default() + }; + let scene = vec![make_text("Hello ghost", style)]; + + let built = build_scene_with_anim( + &scene, + (400.0, 400.0), + BuildAnimationCtx { + time: 1.0, + scenario_time: 1.0, + scene_duration: 6.0, + fps: 30, + }, + ); + + assert_eq!(built.root.children.len(), 3, "2 ghosts + 1 principal"); + for (i, node) in built.root.children[..2].iter().enumerate() { + assert!( + matches!(node.kind, BoxKind::Ghost(_)), + "child {i} must be a ghost node" + ); + assert!( + node.intrinsic.is_some(), + "trail ghost {i} on a content-measured component (text) must carry the same \ + kind of intrinsic measure as the principal, or taffy lays it out at 0x0 and it \ + never paints" + ); + } + } + + #[test] + fn trail_ghosts_share_the_principals_paint_window() { + let scene = vec![ChildComponent { + component: serde_json::from_value(json!({ + "type": "text", + "content": "Hello", + "start_at": 2.0, + "style": { + "animation": [ + { "name": "trail", "copies": 2, "spacing": 0.05, "falloff": 0.6 } + ] + } + })) + .expect("component deserializes"), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let built = build_scene_at_time( + &scene, + (400.0, 400.0), + default_root_css((400.0, 400.0)), + BuildAnimationCtx { + time: 2.0, + scenario_time: 2.0, + scene_duration: 6.0, + fps: 30, + }, + ); + + assert_eq!(built.root.children.len(), 3, "2 ghosts + 1 principal"); + let principal_window = built.root.children[2].window; + assert!( + principal_window.is_some(), + "sanity: start_at must produce a PaintWindow on the principal" + ); + for (i, ghost) in built.root.children[..2].iter().enumerate() { + assert_eq!( + ghost.window, principal_window, + "ghost {i} must share the principal's visibility window, or it keeps painting \ + outside the component's declared start_at/end_at" + ); + } + } + + #[test] + fn tooltip_intrinsic_size_uses_the_cascaded_font_size() { + let scene = vec![ChildComponent { + component: make_card( + vec![ChildComponent { + component: serde_json::from_value(json!({ + "type": "tooltip", + "text": "Hi" + })) + .expect("component deserializes"), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }], + CssStyle { + font_size: Some(rustmotion_core::css::units::Length::Px(48.0)), + width: Some(CSize::Length(CLP::Px(400.0))), + height: Some(CSize::Length(CLP::Px(400.0))), + ..Default::default() + }, + ), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let built = build_scene(&scene, (500.0, 500.0)); + let tooltip_node = &built.root.children[0].children[0]; + let got_width = + fixed_px(tooltip_node.css.width.as_ref()).expect("tooltip gets a fixed px width"); + + let expected_width = measure_text_line_width("Hi", 48.0, "Inter", false) + 24.0; + assert!( + (got_width - expected_width).abs() < 0.5, + "tooltip must size itself off the CASCADED font-size (48px from the parent card), \ + not its own pre-cascade default (13px): got width {got_width}, expected ~{expected_width}" + ); + } + #[test] fn component_kind_labels() { assert_eq!(component_kind(&make_shape(100.0, 50.0).component), "shape"); @@ -2371,6 +2712,54 @@ mod tests { assert_eq!(l.height, 80.0); } + #[test] + fn text_intrinsic_measures_vw_against_the_real_viewport_not_1920x1080() { + use rustmotion_core::engine::box_tree::AvailableSpace; + + let text = Component::Text(crate::text::Text { + content: "WWWWWWWWWW".into(), + max_width: None, + timing: Default::default(), + style: CssStyle { + font_size: Some(rustmotion_core::css::Length::String("5vw".into())), + white_space: Some(rustmotion_core::css::style::WhiteSpace::Nowrap), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + text_shadow: None, + stroke: None, + text_background: None, + caret: None, + states: Vec::new(), + swap: None, + }); + let scene = vec![ChildComponent { + component: text, + 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, (3840.0, 2160.0)); + let node = &built.root.children[0]; + let intrinsic = node + .intrinsic + .as_ref() + .expect("text carries an intrinsic measurer"); + let (natural_w, _) = intrinsic.measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + assert!( + natural_w > 1000.0, + "expected 5vw on a 3840-wide viewport to resolve to a 192px font-size \ + (natural width of ten 'W' glyphs > 1000px), got {natural_w} — intrinsic \ + measurement is still using the hardcoded 1920x1080 fallback" + ); + } + #[test] fn horizontal_divider_stretches_to_parent_width() { let divider = ChildComponent { @@ -2658,6 +3047,40 @@ mod tests { ); } + fn flowing_badge() -> ChildComponent { + serde_json::from_value(serde_json::json!({ + "type": "badge", + "text": "New" + })) + .expect("badge deserializes") + } + + #[test] + fn badge_does_not_stretch_under_the_default_cross_axis() { + let scene = vec![flowing_badge()]; + let built = build_scene_with_root(&scene, (400.0, 200.0), default_root_css((400.0, 200.0))); + assert_eq!( + built.root.children[0].css.align_self, + Some(AlignSelf::FlexStart), + "a badge under the default (stretch) cross-axis must opt out of \ + stretching, or it grows to the parent's full width" + ); + } + + #[test] + fn badge_honours_an_explicit_center_from_its_parent() { + let mut root_css = default_root_css((400.0, 200.0)); + root_css.align_items = Some(AlignItems::Center); + let scene = vec![flowing_badge()]; + let built = build_scene_with_root(&scene, (400.0, 200.0), root_css); + assert_eq!( + built.root.children[0].css.align_self, None, + "a badge whose parent explicitly requests align-items: center \ + must not override it with its own flex-start default — it \ + should inherit the parent's alignment like any other child" + ); + } + #[test] fn line_intrinsic_size_matches_endpoint_bounding_box() { let line = ChildComponent { diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs index 8092d4aa..fed2a699 100644 --- a/crates/rustmotion-components/src/callout.rs +++ b/crates/rustmotion-components/src/callout.rs @@ -6,7 +6,10 @@ use skia_safe::{Canvas, PaintStyle, Path, PathBuilder, RRect, Rect}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{paint_from_hex, typeface_with_fallback, wrap_text}; +use rustmotion_core::engine::renderer::{ + draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, + typeface_with_fallback, wrap_text, +}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -173,16 +176,15 @@ impl Callout { let mut text_paint = paint_from_hex(self.text_color()); text_paint.set_anti_alias(true); + let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); for (i, line) in lines.iter().enumerate() { if line.is_empty() { continue; } - if let Some(blob) = skia_safe::TextBlob::new(line, &font) { - let blob_w = blob.bounds().width(); - let x = text_area_x + (text_area_w - blob_w) / 2.0; - let y = text_y_start + i as f32 * line_height; - canvas.draw_text_blob(&blob, (x, y), &text_paint); - } + let line_w = measure_text_with_fallback(line, &font, &emoji_font, 0.0); + let x = text_area_x + (text_area_w - line_w) / 2.0; + let y = text_y_start + i as f32 * line_height; + draw_text_with_fallback(canvas, line, &font, &emoji_font, 0.0, x, y, &text_paint); } Ok(()) @@ -277,4 +279,113 @@ mod tests { "callout at font-size: 2rem must paint visible text, got {text_ink} pixels" ); } + + fn dump_pixels(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]; + 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"); + buf + } + + #[test] + fn cjk_text_is_drawn_through_the_shared_fallback_primitive() { + let text = "你好世界"; + let callout = Callout { + text: text.to_string(), + arrow_direction: ArrowDirection::default(), + arrow_size: default_arrow_size(), + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(40.0)), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + }; + const W: i32 = 400; + const H: i32 = 200; + + let mut actual = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + callout + .paint(actual.canvas(), W as f32, H as f32, &test_ctx()) + .expect("paint succeeds"); + let actual_pixels = dump_pixels(&mut actual, W, H); + + let font_style = skia_safe::FontStyle::normal(); + let typeface = + rustmotion_core::engine::renderer::typeface_with_fallback("Inter", font_style) + .expect("host must have a fallback typeface"); + let font = skia_safe::Font::from_typeface(typeface, callout.font_size(&test_ctx())); + let (_, metrics) = font.metrics(); + let ascent = -metrics.ascent; + let bubble = callout.bubble_rect(W as f32, H as f32); + let padding = 12.0; + let text_area_x = bubble.left + padding; + let text_area_w = bubble.width() - padding * 2.0; + let line_height = callout.font_size(&test_ctx()) * 1.4; + let lines = rustmotion_core::engine::renderer::wrap_text(text, &font, Some(text_area_w)); + let total_text_h = lines.len() as f32 * line_height; + let text_y_start = bubble.top + (bubble.height() - total_text_h) / 2.0 + ascent; + + let mut expected = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = expected.canvas(); + let rrect = RRect::new_rect_xy(bubble, callout.radius(), callout.radius()); + let mut bg_paint = paint_from_hex(callout.bg_color()); + bg_paint.set_style(PaintStyle::Fill); + bg_paint.set_anti_alias(true); + canvas.draw_rrect(rrect, &bg_paint); + canvas.draw_path(&callout.arrow_path(W as f32, H as f32), &bg_paint); + + let mut text_paint = paint_from_hex(callout.text_color()); + text_paint.set_anti_alias(true); + let emoji_font = rustmotion_core::engine::renderer::emoji_typeface() + .map(|tf| skia_safe::Font::from_typeface(tf, font.size())); + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + let line_w = rustmotion_core::engine::renderer::measure_text_with_fallback( + line, + &font, + &emoji_font, + 0.0, + ); + let x = text_area_x + (text_area_w - line_w) / 2.0; + let y = text_y_start + i as f32 * line_height; + rustmotion_core::engine::renderer::draw_text_with_fallback( + canvas, + line, + &font, + &emoji_font, + 0.0, + x, + y, + &text_paint, + ); + } + } + let expected_pixels = dump_pixels(&mut expected, W, H); + + assert_eq!( + actual_pixels, expected_pixels, + "callout must draw text through draw_text_with_fallback (glyph-coverage and \ + emoji-font fallback, advance-width centering) like every other text-painting \ + component, not through a bare TextBlob::new on a single font centered on its own \ + ink bounds" + ); + } } diff --git a/crates/rustmotion-components/src/chart/axes.rs b/crates/rustmotion-components/src/chart/axes.rs index 6307ad69..363bca57 100644 --- a/crates/rustmotion-components/src/chart/axes.rs +++ b/crates/rustmotion-components/src/chart/axes.rs @@ -24,13 +24,38 @@ pub(super) fn format_number(val: f64) -> String { format!("{:.1}M", val / 1_000_000.0) } else if val.abs() >= 1_000.0 { format!("{:.1}K", val / 1_000.0) - } else if val.fract().abs() < 0.01 { - format!("{}", val as i64) + } else if (val - val.round()).abs() < 1e-9 { + format!("{}", val.round() as i64) } else { format!("{:.1}", val) } } +fn tick_decimals(step: f64) -> usize { + let step = step.abs(); + if !step.is_finite() || step <= 0.0 { + return 0; + } + let mut decimals = 0usize; + let mut scaled = step; + while scaled < 0.999_999 && decimals < 6 { + scaled *= 10.0; + decimals += 1; + } + decimals +} + +pub(super) fn format_tick_for_step(val: f64, step: f64) -> String { + if val.abs() >= 1_000_000.0 { + return format!("{:.1}M", val / 1_000_000.0); + } + if val.abs() >= 1_000.0 { + return format!("{:.1}K", val / 1_000.0); + } + let decimals = tick_decimals(step); + format!("{val:.decimals$}") +} + impl Chart { /// Draw grid lines and axis labels for cartesian charts. /// @@ -63,6 +88,7 @@ impl Chart { // Grid lines + Y labels let grid_steps = 5; let range = max_val - min_val; + let tick_step = range / grid_steps as f64; for i in 0..=grid_steps { let frac = i as f32 / grid_steps as f32; @@ -78,7 +104,7 @@ impl Chart { if self.show_y_labels { let val = min_val + range * frac as f64; - let label = format_number(val); + let label = format_tick_for_step(val, tick_step); let mut label_paint = paint_from_hex(&self.label_color); label_paint.set_anti_alias(true); let label_w = measure_text_with_fallback(&label, &font, &emoji_font, 0.0); @@ -164,6 +190,7 @@ impl Chart { let grid_steps = 5; let range = max_val - min_val; + let tick_step = range / grid_steps as f64; for i in 0..=grid_steps { let frac = i as f32 / grid_steps as f32; @@ -178,7 +205,7 @@ impl Chart { } if self.show_x_labels { - let label = format_number(min_val + range * frac as f64); + let label = format_tick_for_step(min_val + range * frac as f64, tick_step); let mut label_paint = paint_from_hex(&self.label_color); label_paint.set_anti_alias(true); let label_w = measure_text_with_fallback(&label, &font, &emoji_font, 0.0); @@ -199,3 +226,36 @@ impl Chart { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_number_does_not_flip_a_float_noise_integer_to_a_different_branch_than_its_exact_neighbour( + ) { + assert_eq!(format_number(-2.0), "-2"); + assert_eq!(format_number(-1.999_999_999_999_999_6), "-2"); + } + + #[test] + fn format_tick_for_step_keeps_enough_decimals_to_tell_sub_unit_ticks_apart() { + assert_eq!(format_tick_for_step(0.01, 0.01), "0.01"); + assert_eq!(format_tick_for_step(0.0, 0.01), "0.00"); + assert_ne!( + format_tick_for_step(0.01, 0.01), + format_tick_for_step(0.0, 0.01), + "two ticks a step apart must not render identically" + ); + } + + #[test] + fn format_tick_for_step_does_not_flip_branch_on_float_noise() { + assert_eq!(format_tick_for_step(-1.999_999_999_999_999_6, 1.0), "-2"); + } + + #[test] + fn format_tick_for_step_uses_whole_numbers_for_a_whole_number_step() { + assert_eq!(format_tick_for_step(4.0, 2.0), "4"); + } +} diff --git a/crates/rustmotion-components/src/chart/bar.rs b/crates/rustmotion-components/src/chart/bar.rs index 05c59f9f..c07439a7 100644 --- a/crates/rustmotion-components/src/chart/bar.rs +++ b/crates/rustmotion-components/src/chart/bar.rs @@ -405,6 +405,7 @@ mod tests { animation_duration: 1.5, colors: None, inner_radius: 0.6, + max: 100.0, fill_opacity: 0.3, smooth: false, categories: Vec::new(), diff --git a/crates/rustmotion-components/src/chart/line.rs b/crates/rustmotion-components/src/chart/line.rs index 3230efb2..5f291599 100644 --- a/crates/rustmotion-components/src/chart/line.rs +++ b/crates/rustmotion-components/src/chart/line.rs @@ -35,13 +35,37 @@ pub(super) fn series_scale( }) } +pub(super) fn zero_anchored_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 flat = (max_val - min_val).abs() < f64::EPSILON; + let domain_min = if flat { min_val } else { min_val.min(0.0) }; + let domain_max = if flat { max_val } else { max_val.max(0.0) }; + let range = if flat { 1.0 } else { domain_max - domain_min }; + (domain_min, domain_max, move |v: f64| { + if flat { + 0.5 + } else { + ((v - domain_min) / range) as f32 + } + }) +} + impl Chart { pub(super) fn render_line(&self, canvas: &Canvas, w: f32, h: f32, progress: f32) -> Result<()> { let (mt, mr, mb, ml) = self.chart_margins(); let chart_w = w - ml - mr; let chart_h = h - mt - mb; - let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value)); + let (min_val, max_val, norm) = zero_anchored_scale(self.data.iter().map(|d| d.value)); + let zero_y = mt + chart_h - norm(0.0) * chart_h; let n = self.data.len(); let x_labels: Vec = self @@ -77,7 +101,7 @@ impl Chart { if i == 0 { path.move_to((x, y)); - fill_path.move_to((x, mt + chart_h)); + fill_path.move_to((x, zero_y)); fill_path.line_to((x, y)); } else { path.line_to((x, y)); @@ -86,7 +110,7 @@ impl Chart { } let last_x = ml + chart_w; - fill_path.line_to((last_x, mt + chart_h)); + fill_path.line_to((last_x, zero_y)); fill_path.close(); // Clip for animation @@ -133,7 +157,8 @@ impl Chart { let chart_w = w - ml - mr; let chart_h = h - mt - mb; - let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value)); + let (min_val, max_val, norm) = zero_anchored_scale(self.data.iter().map(|d| d.value)); + let zero_y = mt + chart_h - norm(0.0) * chart_h; let n = self.data.len(); let x_labels: Vec = self @@ -175,7 +200,7 @@ impl Chart { if self.smooth && pts.len() >= 3 { // Catmull-Rom -> cubic bezier for smooth curves line_path.move_to(pts[0]); - fill_path.move_to((pts[0].0, mt + chart_h)); + fill_path.move_to((pts[0].0, zero_y)); fill_path.line_to(pts[0]); for i in 0..pts.len() - 1 { @@ -200,7 +225,7 @@ impl Chart { for (i, &(x, y)) in pts.iter().enumerate() { if i == 0 { line_path.move_to((x, y)); - fill_path.move_to((x, mt + chart_h)); + fill_path.move_to((x, zero_y)); fill_path.line_to((x, y)); } else { line_path.line_to((x, y)); @@ -210,7 +235,7 @@ impl Chart { } let last_x = pts.last().map(|p| p.0).unwrap_or(ml + chart_w); - fill_path.line_to((last_x, mt + chart_h)); + fill_path.line_to((last_x, zero_y)); fill_path.close(); // Clip for animation @@ -264,3 +289,112 @@ impl Chart { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::chart::{ChartDataPoint, ChartType}; + use rustmotion_core::css::CssStyle; + use rustmotion_core::traits::TimingConfig; + + fn base_chart(chart_type: ChartType, data: Vec) -> Chart { + Chart { + chart_type, + data, + animated: false, + animation_duration: 1.5, + colors: None, + inner_radius: 0.6, + max: 100.0, + 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, + } + } + + fn point(value: f64) -> ChartDataPoint { + ChartDataPoint { + value, + label: None, + color: None, + } + } + + #[test] + fn zero_anchored_scale_puts_zero_on_the_domain_edge_instead_of_the_series_minimum() { + let (min_val, max_val, norm) = zero_anchored_scale([100.0, 105.0].into_iter()); + assert_eq!(min_val, 0.0); + assert_eq!(max_val, 105.0); + assert!( + norm(100.0) > 0.9, + "100 out of a 0..105 domain must sit near the top, got {}", + norm(100.0) + ); + } + + #[test] + fn zero_anchored_scale_keeps_a_flat_series_centred() { + let (min_val, max_val, norm) = zero_anchored_scale([7.0, 7.0, 7.0].into_iter()); + assert_eq!(min_val, 7.0); + assert_eq!(max_val, 7.0); + assert_eq!(norm(7.0), 0.5); + } + + #[test] + fn a_close_pair_of_far_from_zero_values_does_not_paint_the_smaller_one_at_the_axis_floor() { + const W: i32 = 216; + const H: i32 = 216; + let chart = base_chart(ChartType::Line, vec![point(100.0), point(105.0)]); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + chart + .render_line(canvas, W as f32, H as f32, 1.0) + .expect("paint"); + } + 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 is_dot_ink = |x: i32, y: i32| { + 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 > 200 && r < 100 && g > 100 && g < 160 && b > 200 + }; + let first_point_x = 8; + let near_bottom_y = H - 10; + assert!( + !is_dot_ink(first_point_x, near_bottom_y), + "value 100 of a series that only spans 100..105 must not be painted at \ + the chart floor, as if it were close to zero" + ); + } +} diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs index 72e1b5c6..de1353e4 100644 --- a/crates/rustmotion-components/src/chart/mod.rs +++ b/crates/rustmotion-components/src/chart/mod.rs @@ -109,6 +109,9 @@ pub struct Chart { #[serde(default = "default_inner_radius")] pub inner_radius: f64, + #[serde(default = "default_radial_max")] + pub max: f64, + // Area-specific #[serde(default = "default_fill_opacity")] pub fill_opacity: f32, @@ -174,6 +177,10 @@ fn default_inner_radius() -> f64 { 0.6 } +fn default_radial_max() -> f64 { + 100.0 +} + fn default_fill_opacity() -> f32 { 0.3 } @@ -338,6 +345,7 @@ mod tests { animation_duration: 1.5, colors: None, inner_radius: 0.6, + max: default_radial_max(), fill_opacity: 0.3, smooth: false, categories: Vec::new(), diff --git a/crates/rustmotion-components/src/chart/radial.rs b/crates/rustmotion-components/src/chart/radial.rs index 4e173e33..d5946b49 100644 --- a/crates/rustmotion-components/src/chart/radial.rs +++ b/crates/rustmotion-components/src/chart/radial.rs @@ -20,15 +20,7 @@ impl Chart { let track_width = (max_radius / (n as f32 * 1.5)).clamp(6.0, 20.0); let ring_gap = track_width * 0.5; - // Negative values are clamped: an arc can only sweep forward, so a - // negative one drew a ring backwards from 12 o'clock and read as a - // large positive value. - let max_val = self - .data - .iter() - .map(|d| d.value.max(0.0)) - .fold(0.0_f64, f64::max) - .max(0.001); + let max_val = self.max.max(0.001); for (i, dp) in self.data.iter().enumerate() { let r = max_radius - i as f32 * (track_width + ring_gap); @@ -49,7 +41,8 @@ impl Chart { // Fill arc let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(i)); - let sweep = (dp.value.max(0.0) / max_val) as f32 * 360.0 * progress; + let ratio = (dp.value.max(0.0) / max_val).clamp(0.0, 1.0) as f32; + let sweep = ratio * 360.0 * progress; let mut fill_paint = paint_from_hex(color); fill_paint.set_style(PaintStyle::Stroke); @@ -62,3 +55,95 @@ impl Chart { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::chart::{ChartDataPoint, 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: false, + animation_duration: 1.5, + colors: None, + inner_radius: 0.6, + max: 100.0, + 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, + } + } + + fn pixel(buf: &[u8], w: i32, x: i32, y: i32) -> (u8, u8, u8, u8) { + let idx = ((y * w + x) * 4) as usize; + (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]) + } + + #[test] + fn a_single_datum_below_max_does_not_close_the_ring() { + const W: i32 = 300; + const H: i32 = 300; + let mut chart = base_chart(ChartType::RadialBar); + chart.max = 100.0; + chart.data = vec![ChartDataPoint { + value: 25.0, + label: None, + color: Some("#FF0000".to_string()), + }]; + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + chart + .render_radial_bar(canvas, W as f32, H as f32, 1.0) + .expect("paint"); + } + 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 cx = W / 2; + let cy = H / 2; + let max_radius = (cx.min(cy) as f32) - 16.0; + let bottom_y = cy + max_radius.round() as i32; + let (r, g, b, a) = pixel(&buf, W, cx, bottom_y.min(H - 1)); + let is_red_fill = a > 40 && r > 180 && g < 60 && b < 60; + assert!( + !is_red_fill, + "value 25 of max 100 must leave the far side of the ring unfilled, \ + found the fill color at the bottom of the ring: rgba=({r},{g},{b},{a})" + ); + } +} diff --git a/crates/rustmotion-components/src/chart/scatter.rs b/crates/rustmotion-components/src/chart/scatter.rs index d8555d05..aa720a9b 100644 --- a/crates/rustmotion-components/src/chart/scatter.rs +++ b/crates/rustmotion-components/src/chart/scatter.rs @@ -3,7 +3,7 @@ use skia_safe::{Canvas, PaintStyle}; use rustmotion_core::engine::renderer::paint_from_hex; -use super::axes::format_number; +use super::axes::format_tick_for_step; use super::line::series_scale; use super::Chart; @@ -33,8 +33,9 @@ impl Chart { // axis. Passing `categorical: false` places tick i at i/(n-1) of the // width, which is exactly where those fractions fall. let x_labels: Vec = if self.show_x_labels { + let step = (max_x - min_x) / 5.0; (0..=5) - .map(|i| format_number(min_x + (max_x - min_x) * (i as f64 / 5.0))) + .map(|i| format_tick_for_step(min_x + (max_x - min_x) * (i as f64 / 5.0), step)) .collect() } else { Vec::new() diff --git a/crates/rustmotion-components/src/codeblock/reveal.rs b/crates/rustmotion-components/src/codeblock/reveal.rs index 8f29fbeb..a3a8e688 100644 --- a/crates/rustmotion-components/src/codeblock/reveal.rs +++ b/crates/rustmotion-components/src/codeblock/reveal.rs @@ -33,14 +33,20 @@ pub(super) fn compute_reveal( RevealMode::Typewriter => { let total_chars: usize = highlighted .iter() - .map(|l| l.spans.iter().map(|s| s.text.len()).sum::()) + .map(|l| { + l.spans + .iter() + .map(|s| s.text.chars().count()) + .sum::() + }) .sum(); let visible_chars = (total_chars as f64 * progress).round() as usize; let mut chars_remaining = visible_chars; let mut visible_lines = 0; let mut last_line_chars = None; for line in highlighted { - let line_chars: usize = line.spans.iter().map(|s| s.text.len()).sum(); + let line_chars: usize = + line.spans.iter().map(|s| s.text.chars().count()).sum(); if chars_remaining >= line_chars { chars_remaining -= line_chars; visible_lines += 1; @@ -219,7 +225,7 @@ pub(super) fn draw_single_highlighted_line_partial( }; if text_to_draw.is_empty() { - chars_drawn += span.text.len(); + chars_drawn += span.text.chars().count(); continue; } @@ -248,7 +254,7 @@ pub(super) fn draw_single_highlighted_line_partial( ); let w = measure_text_with_fallback(&text_to_draw, font, &emoji_f, 0.0); cursor_x += w; - chars_drawn += text_to_draw.len(); + chars_drawn += text_to_draw.chars().count(); if let Some(limit) = char_limit { if chars_drawn >= limit { @@ -288,3 +294,129 @@ pub(super) fn draw_single_highlighted_line( cursor_x += w; } } + +#[cfg(test)] +mod tests { + use super::super::highlight::ColoredSpan; + use super::*; + + fn codeblock_with_reveal(duration: f64) -> Codeblock { + serde_json::from_value(serde_json::json!({ + "code": "placeholder", + "reveal": {"mode": "typewriter", "start": 0.0, "duration": duration} + })) + .expect("minimal codeblock JSON must deserialize") + } + + fn line_of(text: &str) -> HighlightedLine { + HighlightedLine { + spans: vec![ColoredSpan { + text: text.to_string(), + r: 255, + g: 255, + b: 255, + a: 255, + }], + } + } + + #[test] + fn typewriter_budget_is_spent_in_characters_not_bytes() { + let layer = codeblock_with_reveal(4.0); + let accented = "éééééééééé"; // 10 chars, 20 UTF-8 bytes + assert_eq!(accented.chars().count(), 10); + assert_eq!(accented.len(), 20); + let highlighted = vec![line_of(accented)]; + + let (visible_lines, last_line_chars, _) = compute_reveal(&layer, 2.0, &highlighted); + + assert_eq!(visible_lines, 1); + assert_eq!( + last_line_chars, + Some(5), + "half a 4s reveal (t=2.0) over 10 characters must budget 5 CHARACTERS, not 10 bytes \ + worth of budget spent as if it were 10 characters" + ); + } + + #[test] + fn char_limit_is_honoured_in_characters_across_a_multibyte_span_boundary() { + let line = HighlightedLine { + spans: vec![ + ColoredSpan { + text: "café".to_string(), + r: 255, + g: 255, + b: 255, + a: 255, + }, + ColoredSpan { + text: "test".to_string(), + r: 255, + g: 255, + b: 255, + a: 255, + }, + ], + }; + + let typeface = rustmotion_core::engine::renderer::typeface_with_fallback( + "Helvetica", + skia_safe::FontStyle::normal(), + ) + .expect("host must have a fallback typeface"); + let font = Font::from_typeface(typeface, 24.0); + + const W: i32 = 400; + const H: i32 = 100; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + draw_single_highlighted_line_partial(canvas, &line, &font, 0.0, 50.0, 1.0, Some(5)); + } + let with_five_chars = max_ink_x(&mut surface, W, H); + + let mut surface_cafe_only = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface_cafe_only.canvas(); + draw_single_highlighted_line_partial(canvas, &line, &font, 0.0, 50.0, 1.0, Some(4)); + } + let with_cafe_only = max_ink_x(&mut surface_cafe_only, W, H); + + assert!( + with_five_chars > with_cafe_only, + "a limit of 5 characters must draw one more glyph than a limit of 4 (the 't' from \ + the second span), not stop after \"café\" because its byte length already reached \ + the limit" + ); + } + + fn max_ink_x(surface: &mut skia_safe::Surface, w: i32, h: 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 maxx = 0; + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + } + } + } + maxx + } +} diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index e67af735..98d12579 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -65,12 +65,13 @@ impl Counter { /// /// The ramp starts at `start_at` and runs for `duration`, falling back to /// the rest of the scene when no duration is given. - fn ramp_progress(&self, time: f64, scene_duration: f64) -> f64 { + fn ramp_progress(&self, time: f64, scene_duration: f64, fps: u32) -> f64 { let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); + let last_frame_offset = if fps > 0 { 1.0 / fps as f64 } else { 0.0 }; let ramp = match self.duration { Some(d) if d > 0.0 => d, - _ => scene_duration - start, + _ => (scene_duration - start - last_frame_offset).max(0.0), }; if ramp > 0.0 { (elapsed / ramp).clamp(0.0, 1.0) @@ -128,7 +129,10 @@ impl Counter { _ => TextAlign::Left, }; - let progress = ease(self.ramp_progress(time, scene_duration), &self.easing); + let progress = ease( + self.ramp_progress(time, scene_duration, ctx.fps), + &self.easing, + ); let value = self.from + (self.to - self.from) * progress; let content = format_counter_value( value, @@ -323,25 +327,38 @@ mod tests { // The behaviour that made counters unreadable: nothing settles early, // so the figure is still moving when the scene cuts away. let c = counter(None, None); - assert!(c.ramp_progress(3.9, 4.0) < 1.0); - assert_eq!(c.ramp_progress(4.0, 4.0), 1.0); + assert!(c.ramp_progress(3.9, 4.0, 30) < 1.0); + assert_eq!(c.ramp_progress(4.0, 4.0, 30), 1.0); + } + + #[test] + fn without_duration_the_count_reaches_its_target_on_the_last_rendered_frame() { + let c = counter(None, None); + let fps = 30u32; + let scene_duration = 3.0; + let last_rendered_frame_time = (scene_duration * fps as f64 - 1.0) / fps as f64; + assert_eq!( + c.ramp_progress(last_rendered_frame_time, scene_duration, fps), + 1.0, + "the count must have fully landed by the last frame that actually gets rendered" + ); } #[test] fn duration_makes_the_count_land_early_and_hold() { let c = counter(Some(1.5), None); - assert_eq!(c.ramp_progress(1.5, 4.0), 1.0); + assert_eq!(c.ramp_progress(1.5, 4.0, 30), 1.0); // Held for the rest of the scene, which is the point. - assert_eq!(c.ramp_progress(3.0, 4.0), 1.0); - assert!((c.ramp_progress(0.75, 4.0) - 0.5).abs() < 1e-9); + assert_eq!(c.ramp_progress(3.0, 4.0, 30), 1.0); + assert!((c.ramp_progress(0.75, 4.0, 30) - 0.5).abs() < 1e-9); } #[test] fn duration_is_measured_from_start_at() { let c = counter(Some(2.0), Some(1.0)); - assert_eq!(c.ramp_progress(1.0, 6.0), 0.0); - assert!((c.ramp_progress(2.0, 6.0) - 0.5).abs() < 1e-9); - assert_eq!(c.ramp_progress(3.0, 6.0), 1.0); + assert_eq!(c.ramp_progress(1.0, 6.0, 30), 0.0); + assert!((c.ramp_progress(2.0, 6.0, 30) - 0.5).abs() < 1e-9); + assert_eq!(c.ramp_progress(3.0, 6.0, 30), 1.0); } #[test] @@ -349,13 +366,16 @@ mod tests { // Deliberate: the author asked for a slow count, and silently speeding // it up would be a surprise. It simply never reaches `to`. let c = counter(Some(10.0), None); - assert!(c.ramp_progress(4.0, 4.0) < 0.5); + assert!(c.ramp_progress(4.0, 4.0, 30) < 0.5); } #[test] fn a_zero_or_negative_duration_falls_back_to_the_scene() { let c = counter(Some(0.0), None); - assert!((c.ramp_progress(2.0, 4.0) - 0.5).abs() < 1e-9); + let fps = 30u32; + let fallback_ramp = 4.0 - 1.0 / fps as f64; + let expected = 2.0 / fallback_ramp; + assert!((c.ramp_progress(2.0, 4.0, fps) - expected).abs() < 1e-9); } // ─── Lot B, wave S: relative `font-size` units ───────────────────────── diff --git a/crates/rustmotion-components/src/gauge.rs b/crates/rustmotion-components/src/gauge.rs index bb939c9d..67640297 100644 --- a/crates/rustmotion-components/src/gauge.rs +++ b/crates/rustmotion-components/src/gauge.rs @@ -6,8 +6,8 @@ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ - draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, + draw_text_with_fallback, emoji_typeface, format_counter_value, measure_text_with_fallback, + paint_from_hex, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -66,6 +66,10 @@ pub struct Gauge { pub end_angle: f32, #[serde(default = "default_show_value")] pub show_value: bool, + #[serde(default)] + pub decimals: u8, + #[serde(default)] + pub suffix: Option, #[serde(default = "default_animated")] pub animated: bool, #[serde(default = "default_animation_duration")] @@ -103,6 +107,11 @@ impl Gauge { 1.0 - (1.0 - p).powi(3) } + fn display_text(&self, progress: f32) -> String { + let display_val = self.min + (self.value - self.min) * progress as f64; + format_counter_value(display_val, self.decimals, &None, &None, &self.suffix) + } + fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) { let w = layout_w; let h = layout_h; @@ -137,8 +146,7 @@ impl Gauge { // Value text if self.show_value { - let display_val = self.min + (self.value - self.min) * progress as f64; - let text = format!("{}", display_val.round() as i64); + let text = self.display_text(progress); let font_size = (radius * 0.45).max(16.0); let font_style = skia_safe::FontStyle::bold(); @@ -212,3 +220,54 @@ impl Painter for Gauge { self.paint(canvas, layout.width, layout.height, ctx.time); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn base_gauge(value: f64, max: f64, decimals: u8) -> Gauge { + Gauge { + value, + min: 0.0, + max, + label: None, + track_color: default_track_color(), + fill_color: default_fill_color(), + track_width: default_track_width(), + start_angle: default_start_angle(), + end_angle: default_end_angle(), + show_value: true, + decimals, + suffix: None, + animated: false, + animation_duration: default_animation_duration(), + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + #[test] + fn display_text_keeps_the_requested_decimals_instead_of_rounding_to_a_whole_number() { + let gauge = base_gauge(4.7, 5.0, 1); + assert_eq!( + gauge.display_text(1.0), + "4.7", + "with decimals: 1 the fill arc at 94% must not read as a rounded \"5\"" + ); + } + + #[test] + fn display_text_defaults_to_a_whole_number_when_decimals_is_unset() { + let gauge = base_gauge(75.0, 100.0, 0); + assert_eq!(gauge.display_text(1.0), "75"); + } + + #[test] + fn display_text_appends_the_suffix() { + let mut gauge = base_gauge(48.0, 100.0, 0); + gauge.suffix = Some("%".to_string()); + assert_eq!(gauge.display_text(1.0), "48%"); + } +} diff --git a/crates/rustmotion-components/src/gif.rs b/crates/rustmotion-components/src/gif.rs index 5a0d918d..181b73a9 100644 --- a/crates/rustmotion-components/src/gif.rs +++ b/crates/rustmotion-components/src/gif.rs @@ -279,13 +279,52 @@ impl Painter for Gif { let row_bytes = gif_width as usize * 4; let data = skia_safe::Data::new_copy(frame_data); if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) { - let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); - let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + draw_fitted(canvas, img, &self.fit, layout); } } } +fn fit_rect(fit: &ImageFit, gif_w: f32, gif_h: f32, target_w: f32, target_h: f32) -> Rect { + match fit { + ImageFit::Fill => Rect::from_xywh(0.0, 0.0, target_w, target_h), + ImageFit::Contain => { + let scale = (target_w / gif_w).min(target_h / gif_h); + let w = gif_w * scale; + let h = gif_h * scale; + Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h) + } + ImageFit::Cover => { + let scale = (target_w / gif_w).max(target_h / gif_h); + let w = gif_w * scale; + let h = gif_h * scale; + Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h) + } + } +} + +fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: &BoxLayout) { + let dst = fit_rect( + fit, + img.width() as f32, + img.height() as f32, + layout.width, + layout.height, + ); + let paint = Paint::default(); + if matches!(fit, ImageFit::Cover) { + canvas.save(); + canvas.clip_rect( + Rect::from_xywh(0.0, 0.0, layout.width, layout.height), + skia_safe::ClipOp::Intersect, + true, + ); + canvas.draw_image_rect(img, None, dst, &paint); + canvas.restore(); + } else { + canvas.draw_image_rect(img, None, dst, &paint); + } +} + #[cfg(test)] mod tests { use super::*; @@ -373,6 +412,83 @@ mod tests { assert_eq!(frame_rect(4, 4, &frame), (3, 3, 1, 1)); } + #[test] + fn fit_contain_letterboxes_instead_of_stretching_to_fill() { + let path = std::env::temp_dir().join(format!( + "rustmotion_gif_fit_{}_{}.gif", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + write_two_frame_gif(&path); + + let gif = Gif { + src: path.to_str().expect("utf-8 path").to_string(), + fit: ImageFit::Contain, + loop_gif: true, + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + }; + + const W: i32 = 100; + const H: i32 = 100; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + ..Default::default() + }; + let ctx = PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }; + gif.paint_content(canvas, &layout, &AnimatedProperties::default(), &ctx); + } + std::fs::remove_file(&path).ok(); + + 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 row_has_ink = |y: i32| (0..W).any(|x| buf[((y * W + x) * 4 + 3) as usize] > 0); + assert!( + !row_has_ink(0) && !row_has_ink(H - 1), + "fit: contain on a 2:1 source in a 1:1 box must letterbox — the top and bottom \ + rows must stay empty, not be stretched over by the source image" + ); + assert!( + row_has_ink(H / 2), + "the middle row must still carry the letterboxed image" + ); + } + /// Releases `DECODE_STALL_MS` back to zero even if the test body /// panics mid-assertion, so a failing run never leaks a stall into /// whatever other test in this binary decodes a GIF next. diff --git a/crates/rustmotion-components/src/icon.rs b/crates/rustmotion-components/src/icon.rs index 812f53c1..5071cdb2 100644 --- a/crates/rustmotion-components/src/icon.rs +++ b/crates/rustmotion-components/src/icon.rs @@ -5,7 +5,9 @@ use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect, SamplingOptions}; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{asset_cache, fetch_icon_svg, icon_cache_key}; +use rustmotion_core::engine::renderer::{ + asset_cache, fetch_icon_svg, icon_cache_key, sandboxed_svg_options, +}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -71,7 +73,7 @@ impl Painter for Icon { return; }; - let opt = usvg::Options::default(); + let opt = sandboxed_svg_options(); let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else { return; }; diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 88c0dc0b..d18e1acc 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -72,20 +72,22 @@ pub fn font_size_ctx(viewport_width: f32, viewport_height: f32, parent_size: f32 } } -/// Same as [`font_size_ctx`], for the `Intrinsic` measurers in this module: -/// they run at `box_builder`/`geometry` construction time, before layout, so -/// there is no real per-frame viewport to hand (see the pre-existing note on -/// `TextIntrinsic::from_parts`, which has the same limitation for -/// `letter-spacing`/`line-height`). Falls back to the engine-wide default -/// 1920×1080 (same as `LengthContext::default()`) so `rem` — which does not -/// depend on the viewport at all — still resolves exactly, and `vw`/`vh` get -/// a reasonable non-zero approximation instead of silently dropping to 0. -/// This can diverge from what `Painter::paint_content` resolves via -/// [`font_size_ctx`] for `vw`/`vh` specifically, on videos that aren't -/// 1920×1080 — closing that fully needs the real `VideoConfig` threaded -/// through `box_builder.rs`/`geometry.rs`, both outside this workstream. +/// Same as [`font_size_ctx`], falling back to the engine-wide default +/// 1920×1080 (same as `LengthContext::default()`) so `rem` still resolves +/// exactly, and `vw`/`vh` get a reasonable non-zero approximation instead of +/// silently dropping to 0. See [`measure_time_font_size_ctx_for_viewport`] +/// for callers that have the scenario's real `VideoConfig` dimensions. pub fn measure_time_font_size_ctx(parent_size: f32) -> LengthContext { - font_size_ctx(1920.0, 1080.0, parent_size) + measure_time_font_size_ctx_for_viewport((1920.0, 1080.0), parent_size) +} + +/// Same as [`measure_time_font_size_ctx`], resolving `vw`/`vh` against +/// `viewport` instead of the 1920×1080 default. +pub fn measure_time_font_size_ctx_for_viewport( + viewport: (f32, f32), + parent_size: f32, +) -> LengthContext { + font_size_ctx(viewport.0, viewport.1, parent_size) } /// Skia-backed intrinsic measurer for [`Text`] (audit #10: despite the name @@ -122,6 +124,12 @@ impl TextIntrinsic { /// or the validator's assumption about what the renderer produces is /// false. pub fn from_text(text: &Text) -> Self { + Self::from_text_for_viewport(text, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_text`], resolving `vw`/`vh` `font-size` against + /// `viewport` instead of the 1920×1080 default. + pub fn from_text_for_viewport(text: &Text, viewport: (f32, f32)) -> Self { let wrap = !matches!( text.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) @@ -135,7 +143,7 @@ impl TextIntrinsic { .all_labels() .max_by_key(|label| label.chars().count()) .unwrap_or(&text.content); - Self::from_parts_with_wrap(widest, &text.style, text.max_width, wrap) + Self::from_parts_with_wrap_for_viewport(widest, &text.style, text.max_width, wrap, viewport) .with_autofit(matches!(text.style.text_autofit, Some(true))) } @@ -161,30 +169,18 @@ 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 *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/src/cli/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). - // `measure_time_font_size_ctx` falls back to the engine-wide default - // viewport (1920×1080) for this reason — see its doc comment. - // - // `font-size` itself, and `letter-spacing`/`line-height`'s `em`/`%` - // (relative to this element's *own*, just-resolved font-size — CSS - // spec, also documented on `CssStyle::letter_spacing_px_ctx`/ - // `line_height_for_ctx`) are resolved together by - // `typography_px_ctx`, which re-derives the right context between - // the two steps. `Text`/`Caption`'s painters resolve the same three - // properties with the real `PaintCtx`'s viewport (lot B, wave S), so - // `rem` (viewport-independent) always agrees between measure and - // paint; `vw`/`vh` can diverge on videos that aren't 1920×1080 — - // closing that fully needs the real `VideoConfig` plumbed through - // `box_builder.rs`/`geometry.rs`, still out of scope for the reasons - // above. - let base_ctx = measure_time_font_size_ctx(0.0); + Self::from_parts_for_viewport(content, style, max_width, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_parts`], resolving `vw`/`vh` `font-size` against + /// `viewport` instead of the 1920×1080 default. + pub fn from_parts_for_viewport( + content: &str, + style: &CssStyle, + max_width: Option, + viewport: (f32, f32), + ) -> Self { + let base_ctx = measure_time_font_size_ctx_for_viewport(viewport, 0.0); let (font_size, letter_spacing, line_height_resolved) = style.typography_px_ctx(&base_ctx, 48.0); Self { @@ -209,7 +205,19 @@ impl TextIntrinsic { max_width: Option, wrap: bool, ) -> Self { - let mut t = Self::from_parts(content, style, max_width); + Self::from_parts_with_wrap_for_viewport(content, style, max_width, wrap, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_parts_with_wrap`], resolving `vw`/`vh` + /// `font-size` against `viewport` instead of the 1920×1080 default. + pub fn from_parts_with_wrap_for_viewport( + content: &str, + style: &CssStyle, + max_width: Option, + wrap: bool, + viewport: (f32, f32), + ) -> Self { + let mut t = Self::from_parts_for_viewport(content, style, max_width, viewport); t.wrap = wrap; t } @@ -510,6 +518,12 @@ pub struct GradientTextIntrinsic(TextIntrinsic); impl GradientTextIntrinsic { pub fn from_gradient_text(t: &GradientText) -> Self { + Self::from_gradient_text_for_viewport(t, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_gradient_text`], resolving `vw`/`vh` `font-size` + /// against `viewport` instead of the 1920×1080 default. + pub fn from_gradient_text_for_viewport(t: &GradientText, viewport: (f32, f32)) -> Self { // max_width comes from CSS style.width if set as a fixed pixel value use rustmotion_core::css::style::Size as CSize; use rustmotion_core::css::units::LengthPercentage; @@ -525,8 +539,10 @@ impl GradientTextIntrinsic { Some(WhiteSpace::Nowrap | WhiteSpace::Pre) ); Self( - TextIntrinsic::from_parts_with_wrap(&t.content, &t.style, max_width, wrap) - .with_autofit(matches!(t.style.text_autofit, Some(true))), + TextIntrinsic::from_parts_with_wrap_for_viewport( + &t.content, &t.style, max_width, wrap, viewport, + ) + .with_autofit(matches!(t.style.text_autofit, Some(true))), ) } } @@ -547,6 +563,12 @@ pub struct CaptionIntrinsic(TextIntrinsic); impl CaptionIntrinsic { pub fn from_caption(c: &Caption) -> Self { + Self::from_caption_for_viewport(c, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_caption`], resolving `vw`/`vh` `font-size` + /// against `viewport` instead of the 1920×1080 default. + pub fn from_caption_for_viewport(c: &Caption, viewport: (f32, f32)) -> Self { let joined = c .words .iter() @@ -563,11 +585,12 @@ impl CaptionIntrinsic { c.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) ); - Self(TextIntrinsic::from_parts_with_wrap( + Self(TextIntrinsic::from_parts_with_wrap_for_viewport( &joined, &c.style, c.max_width, wrap, + viewport, )) } } @@ -594,12 +617,25 @@ pub struct KbdIntrinsic { impl KbdIntrinsic { pub fn from_kbd(k: &Kbd) -> Self { - let fs = k - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), k.font_size); + Self::from_kbd_for_viewport(k, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_kbd`], resolving `vw`/`vh` `font-size` against + /// `viewport` instead of the 1920×1080 default. + pub fn from_kbd_for_viewport(k: &Kbd, viewport: (f32, f32)) -> Self { + let fs = k.style.font_size_px_ctx( + &measure_time_font_size_ctx_for_viewport(viewport, 0.0), + k.font_size, + ); let synthetic_style = synthesize_text_style(&k.style, fs, "SF Mono"); Self { - text: TextIntrinsic::from_parts_with_wrap(&k.key, &synthetic_style, None, false), + text: TextIntrinsic::from_parts_with_wrap_for_viewport( + &k.key, + &synthetic_style, + None, + false, + viewport, + ), h_padding: fs * 0.7, v_padding: fs * 0.4, min_width: fs * 1.8, @@ -626,6 +662,12 @@ pub struct CounterIntrinsic(TextIntrinsic); impl CounterIntrinsic { pub fn from_counter(c: &Counter) -> Self { + Self::from_counter_for_viewport(c, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_counter`], resolving `vw`/`vh` `font-size` + /// against `viewport` instead of the 1920×1080 default. + pub fn from_counter_for_viewport(c: &Counter, viewport: (f32, f32)) -> Self { let absmax = c.from.abs().max(c.to.abs()); let signed = if c.from < 0.0 || c.to < 0.0 { -absmax @@ -634,8 +676,8 @@ impl CounterIntrinsic { }; let display = format_counter_value(signed, c.decimals, &c.separator, &c.prefix, &c.suffix); // Counter is atomic: it never wraps. - Self(TextIntrinsic::from_parts_with_wrap( - &display, &c.style, None, false, + Self(TextIntrinsic::from_parts_with_wrap_for_viewport( + &display, &c.style, None, false, viewport, )) } } @@ -662,6 +704,15 @@ pub struct NumberWheelIntrinsic(TextIntrinsic); impl NumberWheelIntrinsic { pub fn from_number_wheel(w: &crate::number_wheel::NumberWheel) -> Self { + Self::from_number_wheel_for_viewport(w, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_number_wheel`], resolving `vw`/`vh` `font-size` + /// against `viewport` instead of the 1920×1080 default. + pub fn from_number_wheel_for_viewport( + w: &crate::number_wheel::NumberWheel, + viewport: (f32, f32), + ) -> Self { let widest = (0..10) .map(|d| { let ch = char::from_digit(d, 10).expect("0..10 is a digit"); @@ -672,12 +723,14 @@ impl NumberWheelIntrinsic { }) .max_by(|a, b| { let measure = |s: &str| { - TextIntrinsic::from_parts_with_wrap(s, &w.style, None, false) - .measure( - (None, None), - (AvailableSpace::MaxContent, AvailableSpace::MaxContent), - ) - .0 + TextIntrinsic::from_parts_with_wrap_for_viewport( + s, &w.style, None, false, viewport, + ) + .measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ) + .0 }; measure(a) .partial_cmp(&measure(b)) @@ -685,8 +738,8 @@ impl NumberWheelIntrinsic { }) .unwrap_or_else(|| w.value.clone()); // A wheel is atomic: it never wraps. - Self(TextIntrinsic::from_parts_with_wrap( - &widest, &w.style, None, false, + Self(TextIntrinsic::from_parts_with_wrap_for_viewport( + &widest, &w.style, None, false, viewport, )) } } @@ -713,10 +766,17 @@ pub struct BadgeIntrinsic { impl BadgeIntrinsic { pub fn from_badge(b: &Badge) -> Self { + Self::from_badge_for_viewport(b, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_badge`], resolving `vw`/`vh` `font-size` against + /// `viewport` instead of the 1920×1080 default. + pub fn from_badge_for_viewport(b: &Badge, viewport: (f32, f32)) -> Self { let (default_fs, h_pad, v_pad, icon_size) = badge_size_params(&b.badge_size); - let font_size = b - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), default_fs); + let font_size = b.style.font_size_px_ctx( + &measure_time_font_size_ctx_for_viewport(viewport, 0.0), + default_fs, + ); let ratio = font_size / default_fs; let h_padding = h_pad * ratio; let v_padding = v_pad * ratio; @@ -729,7 +789,13 @@ impl BadgeIntrinsic { let synthetic_style = synthesize_text_style(&b.style, font_size, "Inter"); Self { - text: TextIntrinsic::from_parts_with_wrap(&b.text, &synthetic_style, None, false), + text: TextIntrinsic::from_parts_with_wrap_for_viewport( + &b.text, + &synthetic_style, + None, + false, + viewport, + ), h_padding, v_padding, icon_extra, @@ -813,9 +879,16 @@ pub struct TerminalIntrinsic { impl TerminalIntrinsic { pub fn from_terminal(t: &Terminal) -> Self { - let font_size = t - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TERM_FONT_SIZE); + Self::from_terminal_for_viewport(t, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_terminal`], resolving `vw`/`vh` `font-size` + /// against `viewport` instead of the 1920×1080 default. + pub fn from_terminal_for_viewport(t: &Terminal, viewport: (f32, f32)) -> Self { + let font_size = t.style.font_size_px_ctx( + &measure_time_font_size_ctx_for_viewport(viewport, 0.0), + TERM_FONT_SIZE, + ); let line_height = (font_size * TERM_LINE_HEIGHT / TERM_FONT_SIZE).ceil(); let chrome_height = if t.show_chrome { CHROME_HEIGHT } else { 0.0 }; @@ -895,9 +968,16 @@ pub struct TableIntrinsic { impl TableIntrinsic { pub fn from_table(t: &Table) -> Self { - let font_size = t - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TABLE_FONT_SIZE); + Self::from_table_for_viewport(t, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_table`], resolving `vw`/`vh` `font-size` against + /// `viewport` instead of the 1920×1080 default. + pub fn from_table_for_viewport(t: &Table, viewport: (f32, f32)) -> Self { + let font_size = t.style.font_size_px_ctx( + &measure_time_font_size_ctx_for_viewport(viewport, 0.0), + TABLE_FONT_SIZE, + ); let row_height = font_size * DEFAULT_ROW_HEIGHT_RATIO; let total_width = Self::compute_width(t, font_size); @@ -959,10 +1039,17 @@ pub struct CodeblockIntrinsic { impl CodeblockIntrinsic { pub fn from_codeblock(c: &Codeblock) -> Self { + Self::from_codeblock_for_viewport(c, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_codeblock`], resolving `vw`/`vh` `font-size` + /// against `viewport` instead of the 1920×1080 default. + pub fn from_codeblock_for_viewport(c: &Codeblock, viewport: (f32, f32)) -> Self { let font_family = c.style.font_family_or("JetBrains Mono"); - let font_size = c - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), 14.0); + let font_size = c.style.font_size_px_ctx( + &measure_time_font_size_ctx_for_viewport(viewport, 0.0), + 14.0, + ); let font_weight = match &c.style.font_weight { Some(CssFontWeight2::Keyword(CssFontWeightKw2::Bold | CssFontWeightKw2::Bolder)) => { FontWeight::Bold @@ -1036,14 +1123,22 @@ pub struct RichTextIntrinsic { spans: Vec, style: CssStyle, max_width: Option, + viewport: (f32, f32), } impl RichTextIntrinsic { pub fn from_rich_text(rt: &RichText) -> Self { + Self::from_rich_text_for_viewport(rt, (1920.0, 1080.0)) + } + + /// Same as [`Self::from_rich_text`], resolving `vw`/`vh` against + /// `viewport` instead of the 1920×1080 default. + pub fn from_rich_text_for_viewport(rt: &RichText, viewport: (f32, f32)) -> Self { Self { spans: rt.spans.clone(), style: rt.style.clone(), max_width: rt.max_width, + viewport, } } } @@ -1070,8 +1165,14 @@ impl IntrinsicMeasure for RichTextIntrinsic { } }; - let layout = - RichText::compute_layout(&self.spans, &self.style, 1920.0, 1080.0, max_width, -1.0); + let layout = RichText::compute_layout( + &self.spans, + &self.style, + self.viewport.0, + self.viewport.1, + max_width, + -1.0, + ); let line_count = layout.lines.len().max(1) as f32; (layout.max_width, line_count * layout.line_height) } @@ -1298,6 +1399,7 @@ mod tests { spans, style, max_width: None, + viewport: (1920.0, 1080.0), }; let (w, h) = intrinsic.measure( (None, None), @@ -1322,6 +1424,7 @@ mod tests { spans, style, max_width: None, + viewport: (1920.0, 1080.0), }; let (_w_unconstrained, h_unconstrained) = intrinsic.measure( (None, None), @@ -1355,6 +1458,7 @@ mod tests { spans: spans.clone(), style: style.clone(), max_width: None, + viewport: (1920.0, 1080.0), }; let (w, h) = intrinsic.measure( (None, None), diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index f3fb225f..151436ab 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -34,8 +34,9 @@ pub struct LegacyPaintDispatcher<'a> { /// `components[id as usize]` is the component for `id`. Slot 0 is the /// synthetic root and is always `None`. components: &'a [Option<&'a ChildComponent>], - /// Per-node container-stagger delay (indexed like `components`); empty - /// when the caller doesn't carry stagger information. + /// Per-node animation delay — ancestor-stagger plus the node's own + /// `start_at` (indexed like `components`); empty when the caller doesn't + /// carry that information. stagger_delays: &'a [f64], /// Per-node accumulated affine time remap `(scale, shift)` from ancestor /// containers' `time_scale`/`time_offset` (indexed like `components`); @@ -52,10 +53,11 @@ impl<'a> LegacyPaintDispatcher<'a> { } } - /// Build from a [`BuiltScene`], carrying its stagger delays so internal - /// animations shift by the same amount as the CSS overrides, and its - /// per-node time remaps so internal animations (counter, draw_in, - /// typewriter…) advance at the same local time as the CSS overrides. + /// Build from a [`BuiltScene`], carrying its per-node animation delays + /// (stagger plus `start_at`) so internal animations shift by the same + /// amount as the CSS overrides, and its per-node time remaps so internal + /// animations (counter, draw_in, typewriter…) advance at the same local + /// time as the CSS overrides. pub fn for_scene(built: &'a crate::box_builder::BuiltScene<'a>) -> Self { Self { components: &built.components, @@ -97,9 +99,9 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { // the CSS overrides injected at box-tree build time, so we don't // wrap the canvas here. `props` is still needed for internal-only // fields like `draw_progress`, `stroke_width`, `visible_chars*`, - // and `char_animation`. Timeline steps and container-stagger delays - // are folded in so those internal animations shift exactly like the - // CSS overrides do. + // and `char_animation`. Timeline steps and the node's accumulated + // delay (ancestor stagger plus its own `start_at`) are folded in so + // those internal animations shift exactly like the CSS overrides do. let stagger_delay = self .stagger_delays .get(*node_id as usize) @@ -117,7 +119,11 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { .copied() .unwrap_or((1.0, 0.0)); let local_time = frame.time * t_scale + t_shift; - let props = match crate::box_builder::effective_effects(&child.component, stagger_delay) { + let props = match crate::box_builder::effective_effects( + &child.component, + stagger_delay, + local_time, + ) { Some(effects) => resolve_props_for_effects(&effects, local_time, frame.scene_duration), None => AnimatedProperties::default(), }; @@ -181,6 +187,26 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { canvas.restore(); } + + fn paints_own_background(&self, payload: &(dyn std::any::Any + Send + Sync)) -> bool { + let Some(node_id) = payload.downcast_ref::() else { + return false; + }; + let Some(child) = self.lookup(*node_id) else { + return false; + }; + matches!( + child.component, + Component::Codeblock(_) + | Component::Badge(_) + | Component::Callout(_) + | Component::Notification(_) + | Component::Stat(_) + | Component::Tooltip(_) + | Component::Kbd(_) + | Component::Caption(_) + ) + } } fn is_container(c: &Component) -> bool { @@ -368,6 +394,76 @@ mod tests { assert!(buf[2] < 50, "blue should be low, got rgba {:?}", buf); } + #[test] + fn badge_background_is_not_composited_twice() { + let scene = vec![ChildComponent { + component: serde_json::from_value(serde_json::json!({ + "type": "badge", + "text": "X", + "style": { + "width": 100, + "height": 40, + "background": "rgba(255,0,0,0.5)" + } + })) + .expect("component deserializes"), + position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }]; + 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(); + canvas.clear(skia_safe::Color::WHITE); + let dispatcher = LegacyPaintDispatcher::new(&built.components); + let frame = PaintFrame { + time: 0.0, + scenario_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 mut buf = [0u8; 4]; + let info = skia_safe::ImageInfo::new( + (1, 1), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let read_ok = snapshot.read_pixels( + &info, + &mut buf, + 4, + skia_safe::IPoint::new(5, 20), + skia_safe::image::CachingHint::Disallow, + ); + assert!(read_ok, "pixel read should succeed"); + let green_channel_expected_from_a_single_composite_not_a_double_one = buf[1]; + assert!( + green_channel_expected_from_a_single_composite_not_a_double_one > 100, + "50% red-over-white background must be composited once (green ~127), not twice \ + (green ~63): got rgba {:?}", + buf + ); + } + #[test] fn card_background_painted_with_red_shape_inside() { // Card 100×80 at (40,30), green background, contains a red 30×20 shape diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index 4e7fd71e..48ae24c7 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex, - typeface_with_fallback, + sandboxed_svg_options, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -117,7 +117,7 @@ impl List { let img = if let Some(cached) = cache.get(&cache_key) { cached.clone() } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) { - let opt = usvg::Options::default(); + let opt = sandboxed_svg_options(); if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) { let svg_size = tree.size(); if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) { @@ -256,11 +256,18 @@ impl Painter for List { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, ctx: &PaintCtx, ) { + canvas.save(); + canvas.clip_rect( + Rect::from_xywh(0.0, 0.0, layout.width.max(0.0), layout.height.max(0.0)), + skia_safe::ClipOp::Intersect, + true, + ); let _ = self.paint(canvas, ctx); + canvas.restore(); } } @@ -339,4 +346,71 @@ mod tests { "list at font-size: 2rem must paint visible ink, got {lit} lit pixels" ); } + + #[test] + fn paint_content_clips_to_the_layout_box() { + let list = List { + items: vec![ListItem { + text: "a label far too long to fit in a narrow box".to_string(), + icon: None, + checked: None, + }], + variant: ListVariant::Bullet, + gap: default_gap(), + icon_size: default_icon_size(), + icon_color: default_icon_color(), + unchecked_color: default_unchecked_color(), + width: default_width(), + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(20.0)), + color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + }; + const W: i32 = 400; + const H: i32 = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 80.0, + height: 40.0, + ..Default::default() + }; + list.paint_content(canvas, &layout, &AnimatedProperties::default(), &test_ctx()); + } + 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 maxx = 0; + for y in 0..H { + for x in 0..W { + if buf[((y * W + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + } + } + } + assert!( + maxx < 80, + "expected ink clipped to the 80px-wide layout box, got ink up to x={maxx}" + ); + } } diff --git a/crates/rustmotion-components/src/lottie.rs b/crates/rustmotion-components/src/lottie.rs index 6fee0988..979e7a4d 100644 --- a/crates/rustmotion-components/src/lottie.rs +++ b/crates/rustmotion-components/src/lottie.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; @@ -61,36 +64,131 @@ rustmotion_core::impl_traits!(Lottie { Styled => style, }); -impl Lottie { - /// Parse Lottie JSON metadata (fr, ip, op, w, h). - fn parse_metadata(&self) -> Result<(f64, usize, f64, f32, f32)> { - let json_str = if let Some(ref src) = self.src { - std::fs::read_to_string(src).map_err(|e| RustmotionError::LottieRead { - path: src.clone(), - reason: e.to_string(), - })? - } else if let Some(ref data) = self.data { - data.clone() - } else { - return Err(RustmotionError::LottieMissingSrc); - }; +struct ResolvedLottieSource { + bytes: Vec, + fr: f64, + total_frames: usize, + duration: f64, +} - let json: serde_json::Value = serde_json::from_str(&json_str)?; - let fr = json["fr"].as_f64().unwrap_or(30.0); - let ip = json["ip"].as_f64().unwrap_or(0.0); - let op = json["op"].as_f64().unwrap_or(60.0); - let w = json["w"].as_f64().unwrap_or(200.0) as f32; - let h = json["h"].as_f64().unwrap_or(200.0) as f32; - let total_frames = (op - ip) as usize; - let duration = total_frames as f64 / fr; +fn lottie_source_key(lottie: &Lottie) -> Option { + if let Some(ref src) = lottie.src { + Some(format!("src:{src}")) + } else if let Some(ref data) = lottie.data { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + Some(format!("data:{:x}", hasher.finish())) + } else { + None + } +} - Ok((fr, total_frames, duration, w, h)) +fn load_lottie_source(lottie: &Lottie, key: &str) -> Option> { + let bytes = if let Some(ref src) = lottie.src { + match std::fs::read(src) { + Ok(b) => b, + Err(e) => { + if crate::warn_once_for(&format!("lottie-read:{key}")) { + eprintln!( + "Warning: lottie failed to read '{src}': {e} — this component will \ + render nothing." + ); + } + return None; + } + } + } else { + lottie.data.clone().unwrap_or_default().into_bytes() + }; + + let json: serde_json::Value = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(e) => { + if crate::warn_once_for(&format!("lottie-parse:{key}")) { + eprintln!( + "Warning: lottie source is not valid JSON: {e} — this component will \ + render nothing." + ); + } + return None; + } + }; + let fr = json["fr"].as_f64().unwrap_or(30.0); + let ip = json["ip"].as_f64().unwrap_or(0.0); + let op = json["op"].as_f64().unwrap_or(60.0); + let total_frames = (op - ip).max(0.0) as usize; + let duration = if fr > 0.0 { + total_frames as f64 / fr + } else { + 0.0 + }; + + Some(Arc::new(ResolvedLottieSource { + bytes, + fr, + total_frames, + duration, + })) +} + +fn resolved_lottie_source(lottie: &Lottie) -> Option> { + static CACHE: OnceLock>>>> = + OnceLock::new(); + + let key = lottie_source_key(lottie)?; + let cache = CACHE.get_or_init(Default::default); + if let Some(hit) = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&key) + { + return hit.clone(); + } + + let resolved = load_lottie_source(lottie, &key); + cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(key, resolved.clone()); + resolved +} + +impl Lottie { + /// Parse Lottie JSON metadata (fr, total_frames, duration). + fn parse_metadata(&self) -> Result<(f64, usize, f64)> { + if self.src.is_none() && self.data.is_none() { + if crate::warn_once_for("lottie-no-source") { + eprintln!( + "Warning: lottie has neither 'src' nor 'data' — this component will \ + render nothing." + ); + } + return Err(RustmotionError::LottieMissingSrc); + } + resolved_lottie_source(self) + .map(|r| (r.fr, r.total_frames, r.duration)) + .ok_or_else(|| { + RustmotionError::Generic("lottie source could not be read or parsed".to_string()) + }) } /// Get the cache key for a specific frame (frames_dir path). fn cache_key(&self, frame: usize) -> String { - let src = self.src.as_deref().unwrap_or("inline"); - format!("lottie:{}:frame:{}", src, frame) + let source = match (&self.src, &self.data) { + (Some(src), _) => src.clone(), + (None, Some(data)) => { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + format!("inline-{:x}", hasher.finish()) + } + (None, None) => "no-source".to_string(), + }; + let frames_dir = self.frames_dir.as_deref().unwrap_or(""); + format!("lottie:{source}:{frames_dir}:frame:{frame}") } /// Load a pre-rendered frame from frames_dir. @@ -169,6 +267,14 @@ mod native { h } + pub(super) fn checked_pixel_area(width: u32, height: u32) -> Option { + (width as usize).checked_mul(height as usize) + } + + pub(super) fn checked_byte_len(pixel_count: usize) -> Option { + pixel_count.checked_mul(4) + } + type NativeCacheMap = Arc>>>; /// Global frame cache: `FrameKey` → RGBA bytes (width * height * 4). @@ -246,11 +352,15 @@ mod native { let rgba = with_engine(|engine| -> Option> { use thorvg::Paint as ThorPaint; - let mut buffer = vec![0u32; (width * height) as usize]; + let area = checked_pixel_area(width, height)?; + let mut buffer = vec![0u32; area]; let mut canvas = engine.sw_canvas(EngineOption::Default).ok()?; // SAFETY: `buffer` is alive for the duration of this closure; canvas - // borrows it until `canvas.sync()` is called. + // borrows it until `canvas.sync()` is called. `buffer.len()` is + // exactly `width * height` (`checked_pixel_area` above rejects any + // product that would overflow rather than wrap), so `set_target` + // below is never told a surface larger than the buffer backing it. unsafe { canvas .set_target(&mut buffer, width, width, height, ColorSpace::ABGR8888) @@ -277,7 +387,7 @@ mod native { canvas.sync().ok()?; // Reinterpret Vec as Vec (R,G,B,A layout — see doc above). - let byte_len = buffer.len() * 4; + let byte_len = checked_byte_len(buffer.len())?; let mut out = Vec::with_capacity(byte_len); // SAFETY: u32 has no uninitialized padding; the full slice is valid // for any byte reinterpretation. @@ -332,44 +442,13 @@ mod native { f.min(total_frames.saturating_sub(1)) as u32 } - /// Resolve the raw JSON bytes from a `Lottie` component. - /// Returns `None` and prints a one-time warning on file-read failure. - pub(super) fn resolve_json(lottie: &super::Lottie) -> Option> { - if let Some(ref data) = lottie.data { - return Some(data.as_bytes().to_vec()); - } - if let Some(ref src) = lottie.src { - match std::fs::read(src) { - Ok(bytes) => return Some(bytes), - Err(e) => { - let path_hash = fnv1a(src.as_bytes()); - if !failed_sources().contains_key(&path_hash) { - eprintln!( - "[rustmotion] lottie-native: cannot read '{}': {}; \ - further errors for this source will be suppressed", - src, e - ); - failed_sources().insert(path_hash, ()); - } - return None; - } - } - } - None - } - pub(super) fn paint_native( lottie: &super::Lottie, canvas: &skia_safe::Canvas, layout: &rustmotion_core::engine::layout_pass::BoxLayout, ctx: &rustmotion_core::traits::PaintCtx, ) { - let json_bytes = match resolve_json(lottie) { - Some(b) => b, - None => return, - }; - - let Ok((fr, total_frames, duration, _w, _h)) = lottie.parse_metadata() else { + let Some(resolved) = super::resolved_lottie_source(lottie) else { return; }; @@ -383,12 +462,12 @@ mod native { ctx.time, lottie.speed, lottie.repeat, - fr, - total_frames, - duration, + resolved.fr, + resolved.total_frames, + resolved.duration, ); - let rgba = match render_frame(&json_bytes, frame_index, w, h) { + let rgba = match render_frame(&resolved.bytes, frame_index, w, h) { Some(r) => r, None => return, }; @@ -410,14 +489,16 @@ mod native { canvas.draw_image_rect(img, None, dst, &paint); } + #[cfg(test)] + pub(super) use checked_byte_len as test_checked_byte_len; + #[cfg(test)] + pub(super) use checked_pixel_area as test_checked_pixel_area; #[cfg(test)] pub(super) use frame_at_time as test_frame_at_time; #[cfg(test)] pub(super) use native_lottie_cache as test_cache; #[cfg(test)] pub(super) use render_frame as test_render_frame; - #[cfg(test)] - pub(super) use resolve_json as test_resolve_json; } // ─── Painter ───────────────────────────────────────────────────────────────── @@ -430,8 +511,7 @@ impl Painter for Lottie { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - let Ok((fr, total_frames, duration, _intrinsic_w, _intrinsic_h)) = self.parse_metadata() - else { + let Ok((fr, total_frames, duration)) = self.parse_metadata() else { return; }; @@ -480,7 +560,10 @@ impl Painter for Lottie { #[cfg(all(test, feature = "lottie-native"))] mod tests { - use super::native::{test_cache, test_frame_at_time, test_render_frame}; + use super::native::{ + test_cache, test_checked_byte_len, test_checked_pixel_area, test_frame_at_time, + test_render_frame, + }; /// Minimal valid Lottie: 30 fps, 30 frames (1 second), one red 80×80 rect layer. /// Taken verbatim from the spike at lottie-spike-thorvg/src/main.rs. @@ -608,6 +691,31 @@ mod tests { assert_eq!(*buf_fast, *buf_normal); } + #[test] + fn width_times_height_no_longer_wraps_to_zero_at_the_u32_boundary() { + assert_eq!( + test_checked_pixel_area(65_536, 65_536), + Some(4_294_967_296), + "65536*65536 must resolve to its real product, not silently wrap to 0 the way plain \ + u32 multiplication does" + ); + } + + #[test] + fn ordinary_dimensions_still_compute_the_right_area() { + assert_eq!(test_checked_pixel_area(1920, 1080), Some(1920 * 1080)); + } + + #[test] + fn a_byte_length_that_would_overflow_usize_is_rejected_not_wrapped() { + assert_eq!( + test_checked_byte_len(usize::MAX / 2), + None, + "a pixel count whose byte length overflows usize must be rejected, not wrapped" + ); + assert_eq!(test_checked_byte_len(100), Some(400)); + } + // ── Test 4: invalid JSON → no panic, zero pixels ────────────────────────── #[test] fn invalid_json_no_panic_zero_pixels() { @@ -634,7 +742,7 @@ mod tests { // any entry whose src_hash matches the red lottie if frames_dir was set. #[test] fn frames_dir_priority_no_native_cache_entry() { - use super::{native, Lottie}; + use super::Lottie; // Clear the cache so we start clean. test_cache().clear(); @@ -661,9 +769,10 @@ mod tests { // Since we can't call paint_content (needs a Canvas), we verify the // invariant: frames_dir branch returns early → native cache stays empty. // - // We confirm that the frames_dir path is taken by calling `resolve_json` - // (which doesn't touch the cache) and checking the cache is still empty. - let _ = native::test_resolve_json(&lottie); + // We confirm that the frames_dir path is taken by resolving the source + // (which doesn't touch the native frame cache) and checking the cache + // is still empty. + let _ = super::resolved_lottie_source(&lottie); // The important check: native cache has no entry for this lottie. // If frames_dir priority is broken and native was called, an entry would appear. @@ -673,3 +782,105 @@ mod tests { ); } } + +#[cfg(test)] +mod silent_failure_tests { + use super::*; + + #[test] + fn a_missing_lottie_source_file_must_claim_its_warn_once_slot() { + let missing_src = std::env::temp_dir().join(format!( + "rustmotion-lottie-test-missing-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + let _ = std::fs::remove_file(&missing_src); + let src_str = missing_src.to_str().unwrap().to_string(); + + let lottie = Lottie { + src: Some(src_str.clone()), + data: None, + speed: 1.0, + repeat: true, + frames_dir: None, + timing: Default::default(), + style: Default::default(), + timeline: vec![], + stagger: None, + }; + let layout = BoxLayout { + width: 100.0, + height: 100.0, + ..Default::default() + }; + let ctx = PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: 100, + video_height: 100, + stagger_offset: 0.0, + }; + let mut surface = skia_safe::surfaces::raster_n32_premul((100, 100)).unwrap(); + { + let canvas = surface.canvas(); + lottie.paint_content(canvas, &layout, &AnimatedProperties::default(), &ctx); + } + + let key = format!("lottie-read:src:{src_str}"); + assert!( + !crate::warn_once_for(&key), + "paint_content must have claimed this warning slot on the failed-read path — it \ + is still unclaimed (first sighting), meaning a bad lottie src rendered nothing \ + without a trace anywhere" + ); + } +} + +#[cfg(test)] +mod frames_dir_cache_key_tests { + use super::*; + + fn lottie(src: Option<&str>, data: Option<&str>, frames_dir: &str) -> Lottie { + Lottie { + src: src.map(str::to_string), + data: data.map(str::to_string), + speed: 1.0, + repeat: true, + frames_dir: Some(frames_dir.to_string()), + timing: Default::default(), + style: Default::default(), + timeline: vec![], + stagger: None, + } + } + + #[test] + fn distinct_frames_dirs_with_identical_inline_data_do_not_collide() { + let a = lottie(None, Some(r#"{"fr":30}"#), "/frames/a"); + let b = lottie(None, Some(r#"{"fr":30}"#), "/frames/b"); + assert_ne!( + a.cache_key(3), + b.cache_key(3), + "two lottie components with no src and different frames_dir must not share an \ + asset_cache slot" + ); + } + + #[test] + fn distinct_inline_data_with_no_src_do_not_collide() { + let a = lottie(None, Some(r#"{"fr":24}"#), "/frames/shared"); + let b = lottie(None, Some(r#"{"fr":30}"#), "/frames/shared"); + assert_ne!( + a.cache_key(3), + b.cache_key(3), + "two lottie components sharing a frames_dir but declaring different inline data \ + must not share an asset_cache slot" + ); + } +} diff --git a/crates/rustmotion-components/src/notification.rs b/crates/rustmotion-components/src/notification.rs index 4d63a057..40306479 100644 --- a/crates/rustmotion-components/src/notification.rs +++ b/crates/rustmotion-components/src/notification.rs @@ -8,7 +8,7 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex, - typeface_with_fallback, + sandboxed_svg_options, typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -180,7 +180,7 @@ impl Notification { let img = if let Some(cached) = cache.get(&cache_key) { cached.clone() } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) { - let opt = usvg::Options::default(); + let opt = sandboxed_svg_options(); if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) { let svg_size = tree.size(); if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) { diff --git a/crates/rustmotion-components/src/number_wheel.rs b/crates/rustmotion-components/src/number_wheel.rs index ded5fbb0..95d2e2c1 100644 --- a/crates/rustmotion-components/src/number_wheel.rs +++ b/crates/rustmotion-components/src/number_wheel.rs @@ -125,6 +125,8 @@ impl NumberWheel { /// and stops exactly on `revolutions * 10 + target` so that the landing /// is on the requested digit rather than near it. pub(crate) fn reel_position(&self, column: usize, target: u32, time: f64) -> f32 { + let start_at = self.timing.start_at.unwrap_or(0.0); + let time = (time - start_at).max(0.0); let start = self.delay + column as f64 * self.stagger_per_column; let raw = if self.duration <= 0.0 { 1.0 @@ -337,6 +339,23 @@ mod tests { ); } + #[test] + fn reel_position_is_measured_from_start_at_not_scene_time_zero() { + let w = wheel(serde_json::json!({ + "value": "7", "duration": 1.0, "stagger_per_column": 0.0, + "delay": 0.0, "start_at": 1.0 + })); + assert_eq!( + w.reel_position(0, 7, 1.0), + 0.0, + "no time has elapsed since start_at yet, the reel must not have moved" + ); + assert!( + (w.reel_position(0, 7, 2.0) % 10.0 - 7.0).abs() < 1e-4, + "one full duration after start_at, the reel should have landed on 7" + ); + } + #[test] fn columns_land_left_to_right() { let w = wheel(serde_json::json!({ diff --git a/crates/rustmotion-components/src/qrcode.rs b/crates/rustmotion-components/src/qrcode.rs index 194f99d4..a50e76e0 100644 --- a/crates/rustmotion-components/src/qrcode.rs +++ b/crates/rustmotion-components/src/qrcode.rs @@ -48,7 +48,7 @@ impl Painter for QrCode { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, _ctx: &PaintCtx, ) { @@ -60,11 +60,17 @@ impl Painter for QrCode { let modules = code.to_colors(); let module_count = code.width() as f32; - let module_size = self.size / module_count; + let w = layout.width; + let h = layout.height; + const ISO_18004_QUIET_ZONE_MODULES: f32 = 4.0; + let module_size_x = w / (module_count + ISO_18004_QUIET_ZONE_MODULES * 2.0); + let module_size_y = h / (module_count + ISO_18004_QUIET_ZONE_MODULES * 2.0); + let offset_x = ISO_18004_QUIET_ZONE_MODULES * module_size_x; + let offset_y = ISO_18004_QUIET_ZONE_MODULES * module_size_y; let mut bg_paint = skia_safe::Paint::new(color4f_from_hex(&self.background_color), None); bg_paint.set_style(PaintStyle::Fill); - canvas.draw_rect(Rect::from_xywh(0.0, 0.0, self.size, self.size), &bg_paint); + canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, h), &bg_paint); let mut fg_paint = skia_safe::Paint::new(color4f_from_hex(&self.foreground_color), None); fg_paint.set_style(PaintStyle::Fill); @@ -75,13 +81,162 @@ impl Painter for QrCode { let col = (idx % code.width()) as f32; let row = (idx / code.width()) as f32; let rect = Rect::from_xywh( - col * module_size, - row * module_size, - module_size, - module_size, + offset_x + col * module_size_x, + offset_y + row * module_size_y, + module_size_x, + module_size_y, ); canvas.draw_rect(rect, &fg_paint); } } } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::css::CssStyle; + + fn qr(content: &str, size: f32) -> QrCode { + QrCode { + content: content.to_string(), + size, + foreground_color: default_qr_fg(), + background_color: default_qr_bg(), + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn ink_max_xy(surface: &mut skia_safe::Surface, w: i32, h: 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 maxx, mut maxy) = (0, 0); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + (maxx, maxy) + } + + fn pixel_at(surface: &mut skia_safe::Surface, w: i32, x: i32, y: i32) -> [u8; 4] { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, surface.height()), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = vec![0u8; (w * surface.height() * 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 i = ((y * w + x) * 4) as usize; + buf[i..i + 4].try_into().expect("pixel") + } + + #[test] + fn a_quiet_zone_margin_surrounds_the_code() { + let component = qr("https://example.com", 200.0); + + const W: i32 = 200; + const H: i32 = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + ..Default::default() + }; + component.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }, + ); + } + + let px = pixel_at(&mut surface, W, 2, 2); + assert_eq!( + px, + [255, 255, 255, 255], + "expected background-colored quiet zone at (2,2), got {px:?} — the finder pattern \ + must not border the scene directly" + ); + } + + #[test] + fn paints_within_the_layout_box_not_its_own_size_field() { + let code = qr("https://example.com", 300.0); + const W: i32 = 200; + const H: i32 = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 60.0, + height: 60.0, + ..Default::default() + }; + code.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: 200, + video_height: 200, + stagger_offset: 0.0, + }, + ); + } + let (maxx, maxy) = ink_max_xy(&mut surface, W, H); + assert!( + maxx < 60 && maxy < 60, + "expected the code to stay within the 60x60 layout box, got ink up to ({maxx}, {maxy})" + ); + } +} diff --git a/crates/rustmotion-components/src/rating.rs b/crates/rustmotion-components/src/rating.rs index 98efd5a4..609b26bc 100644 --- a/crates/rustmotion-components/src/rating.rs +++ b/crates/rustmotion-components/src/rating.rs @@ -105,6 +105,12 @@ impl Rating { } impl Rating { + fn natural_size(&self) -> (f32, f32) { + let count = self.max as f32; + let w = count * self.size + (count - 1.0).max(0.0) * self.gap; + (w.max(1.0), self.size.max(1.0)) + } + fn paint(&self, canvas: &Canvas, time: f64) { let displayed = self.displayed_value_at(time); let outer_radius = self.size / 2.0; @@ -155,10 +161,106 @@ impl Painter for Rating { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, ctx: &PaintCtx, ) { + let (natural_w, natural_h) = self.natural_size(); + let scale_x = layout.width / natural_w; + let scale_y = layout.height / natural_h; + canvas.save(); + canvas.scale((scale_x, scale_y)); self.paint(canvas, ctx.time); + canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::css::CssStyle; + + fn rating(max: u32, size: f32, gap: f32, value: f64) -> Rating { + Rating { + value, + max, + size, + gap, + filled_color: default_filled_color(), + empty_color: default_empty_color(), + animated: false, + animation_duration: default_animation_duration(), + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn ink_max_xy(surface: &mut skia_safe::Surface, w: i32, h: 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 maxx, mut maxy) = (0, 0); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + (maxx, maxy) + } + + #[test] + fn paint_content_scales_to_the_layout_box_not_its_own_size_field() { + let r = rating(5, 32.0, 4.0, 5.0); + const W: i32 = 200; + const H: i32 = 100; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 60.0, + height: 30.0, + ..Default::default() + }; + r.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 1.0, + scenario_time: 1.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }, + ); + } + let (maxx, maxy) = ink_max_xy(&mut surface, W, H); + assert!( + maxx < 60 && maxy < 30, + "expected ink within the 60x30 layout box, got ink up to ({maxx}, {maxy})" + ); } } diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index 88443163..143a981e 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -52,6 +52,20 @@ impl Painter for Shape { let h = layout.height; let corner_radius = self.style.border_radius_px(); + let stroke_w = self.stroke.as_ref().map(|stroke| { + if props.stroke_width >= 0.0 { + props.stroke_width + } else { + stroke.width + } + }); + let stroke_inset = stroke_w.unwrap_or(0.0) / 2.0; + let (shape_x, shape_y) = (stroke_inset, stroke_inset); + let (shape_w, shape_h) = ( + (w - stroke_inset * 2.0).max(0.0), + (h - stroke_inset * 2.0).max(0.0), + ); + if let Some(fill) = &self.fill { let mut paint = match fill { Fill::Solid(color) => paint_from_hex(color), @@ -123,21 +137,33 @@ impl Painter for Shape { } }; paint.set_style(PaintStyle::Fill); - draw_shape_path(canvas, &self.shape, 0.0, 0.0, w, h, corner_radius, &paint); + draw_shape_path( + canvas, + &self.shape, + shape_x, + shape_y, + shape_w, + shape_h, + corner_radius, + &paint, + ); } if let Some(stroke) = &self.stroke { let mut paint = paint_from_hex(&stroke.color); paint.set_style(PaintStyle::Stroke); - let stroke_w = if props.stroke_width >= 0.0 { - props.stroke_width - } else { - stroke.width - }; + let stroke_w = stroke_w.expect("stroke_w is Some whenever self.stroke is Some"); paint.set_stroke_width(stroke_w); if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { - if let Some(path) = build_shape_path(&self.shape, 0.0, 0.0, w, h, corner_radius) { + if let Some(path) = build_shape_path( + &self.shape, + shape_x, + shape_y, + shape_w, + shape_h, + corner_radius, + ) { let mut measure = skia_safe::PathMeasure::new(&path, false, None); let path_len = measure.length(); if path_len > 0.0 { @@ -150,7 +176,16 @@ impl Painter for Shape { } } - draw_shape_path(canvas, &self.shape, 0.0, 0.0, w, h, corner_radius, &paint); + draw_shape_path( + canvas, + &self.shape, + shape_x, + shape_y, + shape_w, + shape_h, + corner_radius, + &paint, + ); } if let Some(text) = &self.text { @@ -254,3 +289,102 @@ fn render_shape_text( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn ink_bounds(surface: &mut skia_safe::Surface, w: i32, h: i32) -> (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 miny, mut maxx, mut maxy) = (w, h, 0, 0); + for y in 0..h { + for x in 0..w { + if buf[((y * w + x) * 4 + 3) as usize] > 0 { + minx = minx.min(x); + miny = miny.min(y); + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + (minx, miny, maxx, maxy) + } + + #[test] + fn a_thick_stroke_flush_to_the_box_stays_within_it() { + let shape = Shape { + shape: ShapeType::Rect, + text: None, + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + fill: None, + stroke: Some(Stroke { + color: "#FFFFFF".to_string(), + width: 40.0, + }), + }; + + const CANVAS_W: i32 = 300; + const CANVAS_H: i32 = 300; + const OFFSET: f32 = 100.0; + const BOX_W: f32 = 100.0; + const BOX_H: f32 = 100.0; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((CANVAS_W, CANVAS_H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.translate((OFFSET, OFFSET)); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: BOX_W, + height: BOX_H, + ..Default::default() + }; + let ctx = PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: CANVAS_W as u32, + video_height: CANVAS_H as u32, + stagger_offset: 0.0, + }; + shape.paint_content(canvas, &layout, &AnimatedProperties::default(), &ctx); + } + + let (minx, miny, maxx, maxy) = ink_bounds(&mut surface, CANVAS_W, CANVAS_H); + + assert!( + minx as f32 >= OFFSET && miny as f32 >= OFFSET, + "a centre-aligned stroke must not bleed left/above the box (min=({minx},{miny}), \ + box origin=({OFFSET},{OFFSET}))" + ); + assert!( + maxx as f32 <= OFFSET + BOX_W && maxy as f32 <= OFFSET + BOX_H, + "a centre-aligned stroke must not bleed right/below the box (max=({maxx},{maxy}), \ + box far corner=({},{}))", + OFFSET + BOX_W, + OFFSET + BOX_H + ); + } +} diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs index fe181b1f..3fa7274f 100644 --- a/crates/rustmotion-components/src/stat.rs +++ b/crates/rustmotion-components/src/stat.rs @@ -10,7 +10,8 @@ use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, - measure_text_with_fallback, paint_from_hex, parse_hex_color, typeface_with_fallback, + measure_text_with_fallback, paint_from_hex, parse_hex_color, sandboxed_svg_options, + typeface_with_fallback, }; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -249,7 +250,7 @@ impl Stat { } else if let Ok(svg_data) = fetch_icon_svg(icon_id, trend_color, icon_sz, icon_sz) { - let opt = usvg::Options::default(); + let opt = sandboxed_svg_options(); if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) { let svg_size = tree.size(); if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_sz, icon_sz) { diff --git a/crates/rustmotion-components/src/success_check.rs b/crates/rustmotion-components/src/success_check.rs index 239118a4..9f55bcaf 100644 --- a/crates/rustmotion-components/src/success_check.rs +++ b/crates/rustmotion-components/src/success_check.rs @@ -135,7 +135,7 @@ impl Painter for SuccessCheck { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, ctx: &PaintCtx, ) { @@ -152,7 +152,11 @@ impl Painter for SuccessCheck { let scale = 0.72 + 0.28 * phase.arrival; let angle = -18.0 * self.spin * (1.0 - phase.arrival); + let layout_scale_x = layout.width / size.max(1.0); + let layout_scale_y = layout.height / size.max(1.0); + canvas.save(); + canvas.scale((layout_scale_x, layout_scale_y)); canvas.translate((centre, centre)); canvas.scale((scale, scale)); canvas.rotate(angle, None); @@ -263,4 +267,67 @@ mod tests { ); } } + + #[test] + fn paint_content_scales_to_the_layout_box_not_its_own_size_field() { + let c = check(serde_json::json!({ "size": 200.0, "duration": 0.0 })); + const W: i32 = 100; + const H: i32 = 100; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 60.0, + height: 60.0, + ..Default::default() + }; + c.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 1.0, + scenario_time: 1.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }, + ); + } + 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 maxx = 0; + let mut maxy = 0; + for y in 0..H { + for x in 0..W { + if buf[((y * W + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + assert!( + maxx < 60 && maxy < 60, + "expected ink within the 60x60 layout box, got ink up to ({maxx}, {maxy})" + ); + } } diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index ccf9e242..ca367ecf 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -7,10 +7,22 @@ use skia_safe::{ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::asset_cache; +use rustmotion_core::engine::renderer::{asset_cache, sandboxed_svg_options}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum SvgReveal { + /// Trace each path's outline progressively (current/legacy behavior). + #[default] + Stroke, + /// Sweep a mask across each path's full, already-painted shape (fills, + /// gradients included) instead of tracing a contour. + Fill, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Svg { #[serde(default)] @@ -35,6 +47,10 @@ pub struct Svg { /// 0.0 = strictly sequential (default); 1.0 = all paths drawn in parallel. #[serde(default)] pub draw_overlap: f32, + /// How draw-on animation reveals paths: `stroke` traces contours (default, + /// unchanged), `fill` sweeps a mask across each path's full painted shape. + #[serde(default)] + pub reveal: SvgReveal, } fn default_draw_stroke_width() -> f32 { @@ -142,6 +158,127 @@ fn collect_paths( } } +/// Recursively collect each visible path's geometry (with its SVG fill rule +/// applied), for use as a reveal mask in `reveal: fill` mode. Color/stroke +/// don't matter here: the mask only gates which pixels of the already +/// fully-painted raster (gradients included) get copied to the canvas. +fn collect_paths_for_fill(group: &usvg::Group, out: &mut Vec) { + for node in group.children() { + match node { + usvg::Node::Group(g) => { + collect_paths_for_fill(g, out); + } + usvg::Node::Path(p) => { + if !p.is_visible() { + continue; + } + let mut skia_path = tiny_path_to_skia(p.data(), p.abs_transform()); + let fill_type = match p.fill().map(|f| f.rule()) { + Some(usvg::FillRule::EvenOdd) => skia_safe::PathFillType::EvenOdd, + _ => skia_safe::PathFillType::Winding, + }; + skia_path.set_fill_type(fill_type); + out.push(skia_path); + } + _ => {} + } + } +} + +/// Reveal the SVG progressively at `draw_progress` (0..=1) by sweeping a clip +/// mask across each path's full, already fully-painted shape (`full_image`, +/// gradients and all) instead of tracing a stroked contour. Paths are +/// revealed one after another (or with overlap), using the same per-path +/// length-weighted windowing as `paint_draw_on` so the sequential ordering +/// matches the stroke mode. +fn paint_fill_reveal( + canvas: &Canvas, + group: &usvg::Group, + svg_size: usvg::Size, + layout: &BoxLayout, + progress: f32, + draw_overlap: f32, + full_image: &skia_safe::Image, +) { + let progress = progress.clamp(0.0, 1.0); + + let mut paths: Vec = Vec::new(); + collect_paths_for_fill(group, &mut paths); + + if paths.is_empty() { + return; + } + + let scale_x = if svg_size.width() > 0.0 { + layout.width / svg_size.width() + } else { + 1.0 + }; + let scale_y = if svg_size.height() > 0.0 { + layout.height / svg_size.height() + } else { + 1.0 + }; + + let lengths: Vec = paths + .iter() + .map(|path| { + let mut pm = PathMeasure::new(path, false, None); + pm.length() + }) + .collect(); + + let total_length: f32 = lengths.iter().sum(); + if total_length <= 0.0 { + return; + } + + let overlap = draw_overlap.clamp(0.0, 1.0); + let image_dst = Rect::from_xywh(0.0, 0.0, svg_size.width(), svg_size.height()); + let paint = Paint::default(); + + let mut cumulative = 0.0f32; + for (path, length) in paths.iter().zip(lengths.iter()) { + let base_frac = length / total_length; + let window_size = base_frac * (1.0 - overlap) + overlap; + let start_frac = cumulative * (1.0 - overlap); + cumulative += base_frac; + + let local_t = if window_size > 0.0 { + ((progress - start_frac) / window_size).clamp(0.0, 1.0) + } else if progress >= start_frac { + 1.0 + } else { + 0.0 + }; + + if local_t <= 0.0 { + continue; + } + + canvas.save(); + canvas.scale((scale_x, scale_y)); + canvas.clip_path(path, None, true); + + if local_t < 1.0 { + // Sweep left-to-right: reveal a growing slice of this path's own + // bounding box, intersected with the path shape itself above. + let bounds = path.bounds(); + let revealed_w = bounds.width() * local_t; + let sweep = Rect::from_ltrb( + bounds.left, + bounds.top - 1.0, + bounds.left + revealed_w, + bounds.bottom + 1.0, + ); + canvas.clip_rect(sweep, None, true); + } + + canvas.draw_image_rect(full_image, None, image_dst, &paint); + canvas.restore(); + } +} + /// Draw the SVG paths progressively at `draw_progress` (0..=1). /// Uses a dash PathEffect to reveal each path sequentially (or with overlap). fn paint_draw_on( @@ -301,7 +438,7 @@ impl Painter for Svg { return; }; - let opt = usvg::Options::default(); + let opt = sandboxed_svg_options(); let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else { return; }; @@ -311,6 +448,19 @@ impl Painter for Svg { if progress >= 1.0 { // At completion, fall through to normal resvg render so fills are shown. self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size); + } else if self.reveal == SvgReveal::Fill { + let Some(full_image) = self.cached_full_image(layout) else { + return; + }; + paint_fill_reveal( + canvas, + tree.root(), + svg_size, + layout, + progress, + self.draw_overlap, + &full_image, + ); } else { paint_draw_on( canvas, @@ -332,6 +482,20 @@ impl Painter for Svg { impl Svg { /// Normal static render via cached resvg bitmap. fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) { + let Some(img) = self.cached_full_image(layout) else { + return; + }; + + let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); + let paint = Paint::default(); + canvas.draw_image_rect(img, None, dst, &paint); + } + + /// Resolve (and cache) the fully rasterized SVG — fills, gradients and + /// all — at the layout's pixel size. Shared by `paint_static` and the + /// `reveal: fill` draw-on mode, which clips this same raster per path + /// instead of re-deriving flat per-path colors. + fn cached_full_image(&self, layout: &BoxLayout) -> Option { let target_w_opt: Option = if layout.width > 0.0 { Some(layout.width as u32) } else { @@ -350,7 +514,8 @@ impl Svg { target_w_opt.unwrap_or(0), target_h_opt.unwrap_or(0) ) - } else if let Some(ref data) = self.data { + } else { + let data = self.data.as_ref()?; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); @@ -361,61 +526,45 @@ impl Svg { target_w_opt.unwrap_or(0), target_h_opt.unwrap_or(0) ) - } else { - return; }; let cache = asset_cache(); - let img = if let Some(cached) = cache.get(&cache_key) { - cached.clone() - } else { - let svg_data = if let Some(ref src) = self.src { - let Ok(data) = std::fs::read(src) else { return }; - data - } else if let Some(ref data) = self.data { - data.as_bytes().to_vec() - } else { - return; - }; + if let Some(cached) = cache.get(&cache_key) { + return Some(cached.clone()); + } - let opt = usvg::Options::default(); - let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else { - return; - }; + let svg_data = if let Some(ref src) = self.src { + std::fs::read(src).ok()? + } else { + self.data.as_ref()?.as_bytes().to_vec() + }; - let svg_size = tree.size(); - let target_w = target_w_opt.unwrap_or(svg_size.width() as u32); - let target_h = target_h_opt.unwrap_or(svg_size.height() as u32); + let opt = sandboxed_svg_options(); + let tree = usvg::Tree::from_data(&svg_data, &opt).ok()?; - let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else { - return; - }; + let svg_size = tree.size(); + let target_w = target_w_opt.unwrap_or(svg_size.width() as u32); + let target_h = target_h_opt.unwrap_or(svg_size.height() as u32); - let scale_x = target_w as f32 / svg_size.width(); - let scale_y = target_h as f32 / svg_size.height(); - let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); + let mut pixmap = tiny_skia::Pixmap::new(target_w, target_h)?; - resvg::render(&tree, transform, &mut pixmap.as_mut()); + let scale_x = target_w as f32 / svg_size.width(); + let scale_y = target_h as f32 / svg_size.height(); + let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); - let img_data = skia_safe::Data::new_copy(pixmap.data()); - let img_info = ImageInfo::new( - (target_w as i32, target_h as i32), - ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - let Some(decoded) = - skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4) - else { - return; - }; - cache.insert(cache_key, decoded.clone()); - decoded - }; + resvg::render(&tree, transform, &mut pixmap.as_mut()); - let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); - let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + let img_data = skia_safe::Data::new_copy(pixmap.data()); + let img_info = ImageInfo::new( + (target_w as i32, target_h as i32), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let decoded = + skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)?; + cache.insert(cache_key, decoded.clone()); + Some(decoded) } /// Render via resvg when draw-on completes (progress == 1.0). @@ -471,3 +620,231 @@ impl Svg { let _ = svg_data; // only used to accept the lifetime; tree holds the parsed data } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::engine::layout_pass::Insets; + use std::path::{Path, PathBuf}; + + const W: i32 = 100; + const H: i32 = 100; + + fn filled_square_svg() -> Svg { + Svg { + src: None, + data: Some( + r##" + + "## + .to_string(), + ), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Fill, + } + } + + fn test_layout() -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: 1920, + video_height: 1080, + stagger_offset: 0.0, + } + } + + fn red_alpha_at(surface: &mut skia_safe::Surface, x: i32, y: i32) -> (u8, u8, u8, u8) { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (W, H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + 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 idx = ((y * W + x) * 4) as usize; + (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]) + } + + #[test] + fn fill_reveal_paints_interior_pixels_at_partial_progress() { + // A fully-filled 80x80 rect with no stroke. At draw_progress = 0.5 the + // `fill` reveal mode must show painted interior pixels (a swept solid + // region), not just a thin traced outline. + let svg = filled_square_svg(); + let layout = test_layout(); + let props = AnimatedProperties { + draw_progress: 0.5, + ..Default::default() + }; + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + // x=30 is well inside the rect's left half (revealed at progress 0.5 + // under a left-to-right sweep) and far from the outline; a stroke-only + // trace would leave it fully transparent. + let (r, g, b, a) = red_alpha_at(&mut surface, 30, 50); + assert!( + a > 200 && r > 200 && g < 50 && b < 50, + "fill reveal at draw_progress=0.5 must paint filled interior pixels, got rgba=({r},{g},{b},{a}) at (30,50)" + ); + } + + #[test] + fn stroke_reveal_default_leaves_interior_unfilled_at_partial_progress() { + // The default `reveal: stroke` behavior must be unchanged: at partial + // draw_progress, only a thin traced outline is visible, so a deep + // interior pixel stays unpainted. + let mut svg = filled_square_svg(); + svg.reveal = SvgReveal::Stroke; + let layout = test_layout(); + let props = AnimatedProperties { + draw_progress: 0.5, + ..Default::default() + }; + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + let (_, _, _, a) = red_alpha_at(&mut surface, 50, 50); + assert!( + a < 50, + "default stroke reveal must not fill the interior at partial progress, got alpha={a} at (50,50)" + ); + } + + fn write_witness_png(dir: &Path) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join("witness.png"); + let img = image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 255])); + img.save(&path).expect("write witness PNG"); + path + } + + fn scratch_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rm_svg_href_test_{name}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + fn svg_with_embedded_href(data: String) -> Svg { + Svg { + src: None, + data: Some(data), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Stroke, + } + } + + #[test] + fn embedded_image_href_cannot_read_an_arbitrary_local_file_via_static_render() { + let dir = scratch_dir("static"); + let witness_path = write_witness_png(&dir); + let witness_path_str = witness_path.to_str().unwrap(); + + let svg = svg_with_embedded_href(format!( + r##" + + "## + )); + let layout = test_layout(); + let props = AnimatedProperties::default(); + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + let (r, g, b, a) = red_alpha_at(&mut surface, 50, 50); + assert!( + !(b > 200 && r < 50 && g < 50 && a > 200), + "inline svg.data must not read {witness_path_str} via and paint its \ + pixels — got rgba=({r},{g},{b},{a}) at (50,50), which is the witness PNG's solid blue" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn embedded_image_href_cannot_read_an_arbitrary_local_file_via_draw_on_render() { + let dir = scratch_dir("draw-on"); + let witness_path = write_witness_png(&dir); + let witness_path_str = witness_path.to_str().unwrap(); + + let mut svg = svg_with_embedded_href(format!( + r##" + + "## + )); + svg.draw = true; + let layout = test_layout(); + let props = AnimatedProperties::default(); + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + let (r, g, b, a) = red_alpha_at(&mut surface, 50, 50); + assert!( + !(b > 200 && r < 50 && g < 50 && a > 200), + "inline svg.data must not read {witness_path_str} via and paint its \ + pixels — got rgba=({r},{g},{b},{a}) at (50,50), which is the witness PNG's solid blue" + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/rustmotion-components/src/switch.rs b/crates/rustmotion-components/src/switch.rs index 0d6f2a94..c1a18f64 100644 --- a/crates/rustmotion-components/src/switch.rs +++ b/crates/rustmotion-components/src/switch.rs @@ -181,6 +181,14 @@ impl Switch { } impl Switch { + fn natural_size(&self) -> (f32, f32) { + let w = match &self.label { + Some(label) => self.width + switch_label_extra_width(label, self.height).unwrap_or(0.0), + None => self.width, + }; + (w.max(1.0), self.height.max(1.0)) + } + fn paint(&self, canvas: &Canvas, time: f64) { let w = self.width; let h = self.height; @@ -251,11 +259,17 @@ impl Painter for Switch { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, _props: &AnimatedProperties, ctx: &PaintCtx, ) { + let (natural_w, natural_h) = self.natural_size(); + let scale_x = layout.width / natural_w; + let scale_y = layout.height / natural_h; + canvas.save(); + canvas.scale((scale_x, scale_y)); self.paint(canvas, ctx.time); + canvas.restore(); } } @@ -307,4 +321,67 @@ mod tests { }; assert_eq!(w, 500.0, "author's explicit style.width must win"); } + + #[test] + fn paint_content_scales_to_the_layout_box_not_its_own_fields() { + let s = parse(r#"{"type":"switch","width":150,"height":80}"#); + const W: i32 = 200; + const H: i32 = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 60.0, + height: 30.0, + ..Default::default() + }; + s.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }, + ); + } + 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 maxx = 0; + let mut maxy = 0; + for y in 0..H { + for x in 0..W { + if buf[((y * W + x) * 4 + 3) as usize] > 0 { + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + } + assert!( + maxx < 60 && maxy < 30, + "expected ink within the 60x30 layout box, got ink up to ({maxx}, {maxy})" + ); + } } diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs index be6224ec..56da7f72 100644 --- a/crates/rustmotion-components/src/terminal.rs +++ b/crates/rustmotion-components/src/terminal.rs @@ -195,7 +195,9 @@ impl Terminal { let total_chars: usize = self .lines .iter() - .map(|l| Self::line_prefix(&l.line_type).len() + l.text.len()) + .map(|l| { + Self::line_prefix(&l.line_type).chars().count() + l.text.chars().count() + }) .sum(); let visible_chars = (total_chars as f64 * progress).round() as usize; @@ -204,7 +206,8 @@ impl Terminal { let mut partial_chars = None; for line in &self.lines { - let line_chars = Self::line_prefix(&line.line_type).len() + line.text.len(); + let line_chars = Self::line_prefix(&line.line_type).chars().count() + + line.text.chars().count(); if chars_remaining >= line_chars { chars_remaining -= line_chars; visible_lines += 1; @@ -546,4 +549,46 @@ mod tests { "terminal at font-size: 2rem must paint visible text ink, got {text_ink} pixels" ); } + + fn terminal_with_reveal(text: &str, duration: f64) -> Terminal { + Terminal { + lines: vec![TerminalLine { + text: text.to_string(), + line_type: TerminalLineType::Output, + color: None, + }], + theme: TerminalTheme::default(), + title: None, + show_chrome: false, + reveal: Some(CodeblockReveal { + mode: RevealMode::Typewriter, + start: 0.0, + duration, + easing: Default::default(), + }), + auto_scroll: true, + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + } + } + + #[test] + fn typewriter_budget_is_spent_in_characters_not_bytes() { + let accented = "éééééééééé"; // 10 chars, 20 UTF-8 bytes + assert_eq!(accented.chars().count(), 10); + assert_eq!(accented.len(), 20); + let terminal = terminal_with_reveal(accented, 4.0); + + let (visible_lines, partial_chars, _) = terminal.compute_reveal(2.0); + + assert_eq!(visible_lines, 1); + assert_eq!( + partial_chars, + Some(5), + "half a 4s reveal (t=2.0) over 10 characters must budget 5 CHARACTERS, not 10 bytes \ + worth of budget spent as if it were 10 characters" + ); + } } diff --git a/crates/rustmotion-components/src/timeline.rs b/crates/rustmotion-components/src/timeline.rs index d51a0660..0dd2ed03 100644 --- a/crates/rustmotion-components/src/timeline.rs +++ b/crates/rustmotion-components/src/timeline.rs @@ -118,7 +118,7 @@ rustmotion_core::impl_traits!(Timeline { }); impl Timeline { - fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) { + fn paint(&self, canvas: &Canvas, props: &AnimatedProperties, layout_size: (f32, f32)) { let n = self.steps.len(); if n == 0 { return; @@ -141,12 +141,14 @@ impl Timeline { self.fill_progress }; + let (w, h) = layout_size; match self.direction { TimelineDirection::Horizontal => { self.render_horizontal( canvas, n, r, + w, fill_progress, &font, &icon_font, @@ -160,6 +162,7 @@ impl Timeline { canvas, n, r, + h, fill_progress, &font, &icon_font, @@ -178,6 +181,7 @@ impl Timeline { canvas: &Canvas, n: usize, r: f32, + total_w: f32, fill_progress: f32, font: &Font, icon_font: &Font, @@ -185,7 +189,6 @@ impl Timeline { sublabel_font: &Font, ascent: f32, ) { - let total_w = self.width; let bar_y = r; // Center of nodes // Node 0 used to sit at local x=0 and node n-1 at x=total_w, so @@ -327,6 +330,7 @@ impl Timeline { canvas: &Canvas, n: usize, r: f32, + layout_h: f32, fill_progress: f32, font: &Font, icon_font: &Font, @@ -334,9 +338,9 @@ impl Timeline { _sublabel_font: &Font, _ascent: f32, ) { - let spacing = 80.0; let bar_x = r; - let total_h = if n > 1 { (n - 1) as f32 * spacing } else { 0.0 }; + let total_h = (layout_h - 2.0 * r).max(0.0); + let spacing = if n > 1 { total_h / (n - 1) as f32 } else { 0.0 }; // Node 0 used to sit at local y=0, so its circle (radius `r`) bled // `r` px above the assigned box's top edge — same class of bug as // the horizontal direction's left/right overflow. Insetting every @@ -437,11 +441,11 @@ impl Painter for Timeline { fn paint_content( &self, canvas: &Canvas, - _layout: &BoxLayout, + layout: &BoxLayout, props: &AnimatedProperties, _ctx: &PaintCtx, ) { - self.paint(canvas, props); + self.paint(canvas, props, (layout.width, layout.height)); } } @@ -528,7 +532,7 @@ mod tests { { let canvas = surface.canvas(); let props = AnimatedProperties::default(); - tl.paint(canvas, &props); + tl.paint(canvas, &props, (tl.width, H as f32)); } let (minx, maxx, _miny, _maxy) = ink_bounds(&mut surface, W, H).expect("timeline must paint something"); @@ -564,10 +568,66 @@ mod tests { { let canvas = surface.canvas(); let props = AnimatedProperties::default(); - tl.paint(canvas, &props); + tl.paint(canvas, &props, (tl.width, H as f32)); } let (minx, maxx, _miny, _maxy) = ink_bounds(&mut surface, W, H).expect("timeline must paint something"); assert!(minx >= 0 && maxx < W); } + + #[test] + fn paint_content_honours_the_layout_box_not_its_own_width_field() { + let tl = Timeline { + steps: vec![step("A"), step("B"), step("C")], + width: default_timeline_width(), + direction: TimelineDirection::Horizontal, + node_radius: default_node_radius(), + bar_color: default_bar_color(), + bar_fill_color: default_bar_fill_color(), + bar_height: default_bar_height(), + fill_progress: default_fill_progress(), + font_size: default_label_font_size(), + label_color: default_label_color(), + sublabel_color: default_sublabel_color(), + timing: Default::default(), + style: CssStyle::default(), + timeline: Vec::new(), + stagger: None, + }; + const W: i32 = 200; + const H: i32 = 100; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: 120.0, + height: 100.0, + ..Default::default() + }; + tl.paint_content( + canvas, + &layout, + &AnimatedProperties::default(), + &PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + }, + ); + } + let (minx, maxx, _miny, _maxy) = + ink_bounds(&mut surface, W, H).expect("timeline must paint something"); + assert!(minx >= 0, "ink starts left of the box at x={minx}"); + assert!( + maxx < 120, + "expected ink within the 120px-wide layout box, got ink up to x={maxx}" + ); + } } diff --git a/crates/rustmotion-components/src/treemap.rs b/crates/rustmotion-components/src/treemap.rs index fb7872b5..3fdd00ca 100644 --- a/crates/rustmotion-components/src/treemap.rs +++ b/crates/rustmotion-components/src/treemap.rs @@ -138,7 +138,7 @@ impl Treemap { .data .iter() .enumerate() - .map(|(i, item)| (item.value, i)) + .map(|(i, item)| (item.value.max(0.0), i)) .collect(); sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); @@ -428,4 +428,46 @@ mod tests { assert!(treemap.progress_at(2.75) < 1.0); assert_eq!(treemap.progress_at(3.5), 1.0); } + + #[test] + fn a_negative_item_does_not_push_the_others_past_the_box() { + const CANVAS_W: i32 = 400; + const LAYOUT_W: i32 = 300; + const H: i32 = 200; + let mut treemap = base_treemap(vec![ + TreemapItem { + label: None, + value: 100.0, + color: None, + }, + TreemapItem { + label: None, + value: -20.0, + color: None, + }, + ]); + treemap.gap = 0.0; + treemap.animated = false; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((CANVAS_W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + treemap.paint(canvas, LAYOUT_W as f32, H as f32, 0.0); + } + let buf = read_rgba(&mut surface, CANVAS_W, H); + let mid_y = H / 2; + let is_first_item_ink = |x: i32| { + let idx = ((mid_y * CANVAS_W + x) * 4) as usize; + let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]); + a > 40 && r < 100 && g > 100 && g < 160 && b > 200 + }; + for x in (LAYOUT_W + 5)..CANVAS_W { + assert!( + !is_first_item_ink(x), + "the 100-value rect must not extend past the component's own \ + layout width ({LAYOUT_W}px), found its color at x={x}" + ); + } + } } diff --git a/crates/rustmotion-core/src/css/cascade.rs b/crates/rustmotion-core/src/css/cascade.rs index eaa5deec..acd8b079 100644 --- a/crates/rustmotion-core/src/css/cascade.rs +++ b/crates/rustmotion-core/src/css/cascade.rs @@ -16,7 +16,6 @@ //! - `white-space` //! - `overflow-wrap` //! - `visibility` -//! - `text-decoration` (not strictly inheritable but used as such here) //! //! All other properties (background, padding, border, transform, etc.) are //! NOT inherited. @@ -59,9 +58,6 @@ pub fn inherit_from(parent: &CssStyle, child: &mut CssStyle) { if child.visibility.is_none() { child.visibility = parent.visibility; } - if child.text_decoration.is_none() { - child.text_decoration = parent.text_decoration.clone(); - } } #[cfg(test)] diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 6be471f3..8f6f6b24 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -118,7 +118,6 @@ pub struct CssStyle { pub white_space: Option, pub overflow_wrap: Option, pub text_overflow: Option, - pub text_decoration: Option, /// When `true` on `text`/`gradient_text`, the effective `font-size` is /// shrunk (never grown) until the content fits the box it was assigned, /// instead of overflowing it. This is what lets an author declare "this @@ -1009,34 +1008,6 @@ pub enum TextOverflow { Ellipsis, } -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] -#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] -pub struct TextDecoration { - pub line: Option, - pub style: Option, - pub color: Option, - pub thickness: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum TextDecorationLine { - None, - Underline, - Overline, - LineThrough, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum TextDecorationStyle { - Solid, - Double, - Dotted, - Dashed, - Wavy, -} - // ---- Color ---- /// Typed color. Strings are parsed lazily ("#rgb", "rgba(..)", named colors). @@ -1858,4 +1829,19 @@ mod tests { "must not emit the legacy snake_case key any more" ); } + + #[test] + fn text_decoration_is_a_named_unknown_field_not_a_silent_no_op() { + let err = + serde_json::from_str::(r#"{ "text-decoration": { "line": "underline" } }"#) + .expect_err( + "text-decoration must be rejected now that it carries no rendering effect, \ + instead of parsing to a value nothing paints", + ); + assert!( + err.to_string().contains("text-decoration") + || err.to_string().contains("text_decoration"), + "the deny_unknown_fields error must name the field, got: {err}" + ); + } } diff --git a/crates/rustmotion-core/src/css/taffy_bridge.rs b/crates/rustmotion-core/src/css/taffy_bridge.rs index 7ee1fbce..a7dd2fbb 100644 --- a/crates/rustmotion-core/src/css/taffy_bridge.rs +++ b/crates/rustmotion-core/src/css/taffy_bridge.rs @@ -11,7 +11,7 @@ use taffy::prelude as tf; use super::style::{ AlignContent, AlignItems, AlignSelf, BoxSizing, CssStyle, Display, Edges, FlexDirection, FlexWrap, Gap, GridAutoFlow, GridLine, GridLineEnd, GridTrack, GridTrackKeyword, - JustifyContent, JustifyItems, JustifySelf, Overflow, Position, Size, + JustifyContent, JustifyItems, JustifySelf, Overflow, Position, Size, SizeKeyword, }; use super::units::{LengthContext, LengthPercentage, ParsedLength}; @@ -50,12 +50,19 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { // Display style.display = match css.display { + None => tf::Display::Block, Some(Display::None) => tf::Display::None, Some(Display::Block) => tf::Display::Block, Some(Display::Flex) => tf::Display::Flex, Some(Display::Grid) => tf::Display::Grid, - // inline-block / contents → fall back to block in our scope - _ => tf::Display::Block, + Some(Display::InlineBlock) => { + warn_unsupported_display_once("inline-block"); + tf::Display::Block + } + Some(Display::Contents) => { + warn_unsupported_display_once("contents"); + tf::Display::Block + } }; // Position @@ -105,14 +112,13 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { style.border = border_widths(css.border.as_ref(), ctx); // Flex - if let Some(d) = css.flex_direction { - style.flex_direction = match d { - FlexDirection::Row => tf::FlexDirection::Row, - FlexDirection::RowReverse => tf::FlexDirection::RowReverse, - FlexDirection::Column => tf::FlexDirection::Column, - FlexDirection::ColumnReverse => tf::FlexDirection::ColumnReverse, - }; - } + style.flex_direction = match css.flex_direction { + Some(FlexDirection::Row) => tf::FlexDirection::Row, + Some(FlexDirection::RowReverse) => tf::FlexDirection::RowReverse, + Some(FlexDirection::Column) => tf::FlexDirection::Column, + Some(FlexDirection::ColumnReverse) => tf::FlexDirection::ColumnReverse, + None => tf::FlexDirection::Column, + }; if let Some(w) = css.flex_wrap { style.flex_wrap = match w { FlexWrap::Nowrap => tf::FlexWrap::NoWrap, @@ -238,6 +244,23 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { style } +fn warn_unsupported_display_once(keyword: &'static str) { + static WARNED_INLINE_BLOCK: std::sync::Once = std::sync::Once::new(); + static WARNED_CONTENTS: std::sync::Once = std::sync::Once::new(); + let once = if keyword == "contents" { + &WARNED_CONTENTS + } else { + &WARNED_INLINE_BLOCK + }; + once.call_once(|| { + eprintln!( + "Warning: `display: {keyword}` is not supported by the layout engine — falling back \ + to `display: block`, which generates its own box and consumes a flex line, unlike \ + the real `{keyword}` semantics." + ); + }); +} + /// Resolve `padding` + `border` width into a single content-box inset, in /// px, per axis: `(horizontal, vertical)` i.e. `(left + right, top + /// bottom)`. @@ -265,6 +288,15 @@ pub(crate) fn content_box_inset(css: &CssStyle, ctx: &ConversionContext) -> (f32 ) } +fn border_side_style( + b: &super::style::BorderEdges, + side: Option<&super::style::BorderSide>, +) -> super::style::BorderStyle { + side.and_then(|s| s.style) + .or(b.style) + .unwrap_or(super::style::BorderStyle::Solid) +} + fn resolve_border_widths_px( b: Option<&super::style::BorderEdges>, ctx: &ConversionContext, @@ -274,6 +306,9 @@ fn resolve_border_widths_px( }; let uniform = b.width.as_ref().map(Edges::resolve); let pick_side = |side: Option<&super::style::BorderSide>, idx: usize| -> f32 { + if matches!(border_side_style(b, side), super::style::BorderStyle::None) { + return 0.0; + } if let Some(side) = side { if let Some(w) = side.width.as_ref() { return w.resolve(&ctx.length); @@ -404,6 +439,22 @@ fn lp_to_lp_auto( } } +fn warn_unsupported_size_keyword_once(k: SizeKeyword) { + static WARNED_SIZE_KEYWORD: std::sync::Once = std::sync::Once::new(); + let label = match k { + SizeKeyword::MaxContent => "max-content", + SizeKeyword::MinContent => "min-content", + SizeKeyword::FitContent => "fit-content", + }; + WARNED_SIZE_KEYWORD.call_once(|| { + eprintln!( + "Warning: `{label}` is not supported for width/height/min-*/max-*/flex-basis — \ + taffy's `Dimension` has no content-sizing keyword outside grid track sizing. \ + Falling back to `auto`." + ); + }); +} + /// Convert `Size` → taffy `Dimension`. fn size_to_dim(s: Option<&Size>, ctx: &ConversionContext) -> tf::Dimension { let Some(s) = s else { @@ -421,9 +472,8 @@ fn size_to_dim(s: Option<&Size>, ctx: &ConversionContext) -> tf::Dimension { ParsedLength::Vh(p) => tf::Dimension::length(p / 100.0 * ctx.length.viewport_height), ParsedLength::Fr(_) => tf::Dimension::auto(), }, - Size::Keyword(_) => { - // taffy 0.10 supports max-content / min-content / fit-content via Dimension. - // We map them to `auto` for now; refine later if needed. + Size::Keyword(k) => { + warn_unsupported_size_keyword_once(*k); tf::Dimension::auto() } } @@ -483,6 +533,9 @@ fn border_widths( // Per-side overrides take precedence over the uniform `width`. let uniform = b.width.as_ref().map(|e| e.resolve()); let pick_side = |side: Option<&super::style::BorderSide>, idx: usize| -> tf::LengthPercentage { + if matches!(border_side_style(b, side), super::style::BorderStyle::None) { + return tf::LengthPercentage::length(0.0); + } if let Some(side) = side { if let Some(w) = side.width.as_ref() { let lp = LengthPercentage::Px(w.resolve(&ctx.length)); @@ -668,6 +721,22 @@ mod tests { assert_eq!(s.gap.height, tf::LengthPercentage::length(16.0)); } + #[test] + fn flex_display_without_explicit_direction_defaults_to_column() { + let css = CssStyle { + display: Some(Display::Flex), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!( + s.flex_direction, + tf::FlexDirection::Column, + "a `display: flex` container with no `flex-direction` must default to \ + Column, matching SKILL.md's documented default and the scene root's \ + behavior — taffy's own default (Row) must not leak through" + ); + } + #[test] fn padding_uniform_resolved() { let css = CssStyle { @@ -934,4 +1003,75 @@ mod tests { let s = to_taffy_style(&css, &ctx()); assert_eq!(s.justify_self, None); } + + #[test] + fn border_style_none_zeroes_uniform_width_in_taffy() { + let css = CssStyle { + border: Some(BorderEdges { + width: Some(Edges::Uniform(LengthPercentage::Px(10.0))), + style: Some(BorderStyle::None), + ..Default::default() + }), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.border.top, tf::LengthPercentage::length(0.0)); + assert_eq!(s.border.right, tf::LengthPercentage::length(0.0)); + assert_eq!(s.border.bottom, tf::LengthPercentage::length(0.0)); + assert_eq!(s.border.left, tf::LengthPercentage::length(0.0)); + } + + #[test] + fn border_style_none_on_one_side_only_zeroes_that_side() { + let css = CssStyle { + border: Some(BorderEdges { + width: Some(Edges::Uniform(LengthPercentage::Px(10.0))), + style: Some(BorderStyle::Solid), + top: Some(BorderSide { + style: Some(BorderStyle::None), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!(s.border.top, tf::LengthPercentage::length(0.0)); + assert_eq!(s.border.left, tf::LengthPercentage::length(10.0)); + } + + #[test] + fn content_box_inset_agrees_with_taffy_on_border_style_none() { + let css = CssStyle { + border: Some(BorderEdges { + width: Some(Edges::Uniform(LengthPercentage::Px(10.0))), + style: Some(BorderStyle::None), + ..Default::default() + }), + ..Default::default() + }; + let (h, v) = content_box_inset(&css, &ctx()); + assert_eq!( + h, 0.0, + "a border that never paints must not narrow content_box_inset either" + ); + assert_eq!(v, 0.0); + } + + #[test] + fn border_style_solid_default_keeps_width_when_style_unset() { + let css = CssStyle { + border: Some(BorderEdges { + width: Some(Edges::Uniform(LengthPercentage::Px(10.0))), + ..Default::default() + }), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!( + s.border.top, + tf::LengthPercentage::length(10.0), + "an unset style defaults to Solid (paint_pass.rs's own default), not None" + ); + } } diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index ba343334..415b7553 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -336,7 +336,13 @@ pub fn ease(t: f64, easing: &EasingType) -> f64 { } } EasingType::Bounce => bounce_ease_out(t), - EasingType::Spring => t, // Spring handled separately + EasingType::Spring => spring_value( + t, + &SpringConfig { + duration: Some(1.0), + ..SpringConfig::default() + }, + ), EasingType::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_ease(t, *x1, *y1, *x2, *y2), } } @@ -412,6 +418,33 @@ fn ease_in_out_cubic(t: f64) -> f64 { } } +#[cfg(test)] +mod generic_ease_spring_tests { + use super::*; + + #[test] + fn spring_easing_is_no_longer_a_silent_linear_passthrough() { + let linear = 0.5; + let spring = ease(0.5, &EasingType::Spring); + assert!( + (spring - linear).abs() > 0.05, + "\"spring\" easing outside a keyframe segment (wiggle, motion_path, scene \ + transitions) must apply the spring's shape, not silently fall back to linear: \ + got {spring}, linear would be {linear}" + ); + } + + #[test] + fn spring_easing_is_still_pinned_at_both_ends_like_every_other_easing() { + assert_eq!(ease(0.0, &EasingType::Spring), 0.0); + let at_one = ease(1.0, &EasingType::Spring); + assert!( + (at_one - 1.0).abs() < 0.01, + "spring easing must settle close to 1.0 by t=1.0, got {at_one}" + ); + } +} + // ─── Spring solver ────────────────────────────────────────────────────────── /// Default `rest_threshold` (fraction of the 0→1 travel) used by @@ -1019,7 +1052,12 @@ fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue { let progress = match segment_easing { EasingType::Spring => { - let spring_config = anim.spring.clone().unwrap_or_default(); + let mut spring_config = anim.spring.clone().unwrap_or_default(); + if spring_config.duration.is_none() + && spring_rest_time(&spring_config) > segment_duration + { + spring_config.duration = Some(segment_duration); + } spring_value(local_t * segment_duration, &spring_config) } other => ease(local_t, other), @@ -3140,6 +3178,141 @@ mod spring_duration_tests { } } +#[cfg(test)] +mod spring_keyframe_segment_tests { + use super::*; + + fn two_point_spring_animation( + property: &str, + segment_duration: f64, + v0: f64, + v1: f64, + spring: SpringConfig, + ) -> Animation { + Animation { + property: property.to_string(), + keyframes: vec![ + Keyframe { + time: 0.0, + value: KeyframeValue::Number(v0), + easing: None, + }, + Keyframe { + time: segment_duration, + value: KeyframeValue::Number(v1), + easing: None, + }, + ], + easing: EasingType::Spring, + spring: Some(spring), + } + } + + #[test] + fn a_spring_without_an_explicit_duration_nearly_settles_by_the_segment_end() { + let segment_duration = 0.3; + let anim = two_point_spring_animation( + "scale", + segment_duration, + 0.0, + 100.0, + SpringConfig { + damping: 2.0, + stiffness: 100.0, + mass: 1.0, + duration: None, + rest_threshold: None, + }, + ); + + let just_inside_the_segment = + resolve_keyframe_track(&anim, segment_duration * 0.999).as_f64(); + assert!( + (just_inside_the_segment - 100.0).abs() < 5.0, + "value just before the keyframe's own declared end should already be close to the \ + target instead of overshooting far past it: got {just_inside_the_segment}" + ); + } + + #[test] + fn a_spring_that_already_settles_in_time_keeps_its_authored_shape() { + let segment_duration = 1.5; + let damping = 20.0; + let stiffness = 100.0; + let mass = 1.0; + let natural_rest = spring_rest_time(&SpringConfig { + damping, + stiffness, + mass, + duration: None, + rest_threshold: None, + }); + assert!( + natural_rest < segment_duration, + "test setup: this spring must already settle within the segment, got rest_time \ + {natural_rest} for a {segment_duration}s segment" + ); + + let anim = two_point_spring_animation( + "scale", + segment_duration, + 0.0, + 100.0, + SpringConfig { + damping, + stiffness, + mass, + duration: None, + rest_threshold: None, + }, + ); + + let mid_segment_time = segment_duration * 0.5; + let got = resolve_keyframe_track(&anim, mid_segment_time).as_f64(); + let unrescaled = 100.0 * spring_value_raw(mid_segment_time, damping, stiffness, mass); + assert!( + (got - unrescaled).abs() < 1e-9, + "a spring whose natural settle time already fits inside the segment must keep its \ + author-chosen shape, not be rescaled to fit tighter: got {got}, expected {unrescaled}" + ); + } + + #[test] + fn an_explicit_spring_duration_is_not_overridden_by_the_segment_span() { + let anim = two_point_spring_animation( + "scale", + 10.0, + 0.0, + 100.0, + SpringConfig { + damping: 6.0, + stiffness: 120.0, + mass: 1.0, + duration: Some(0.8), + rest_threshold: None, + }, + ); + + let at_the_authored_duration = resolve_keyframe_track(&anim, 0.8).as_f64(); + let reference = 100.0 + * spring_value( + 0.8, + &SpringConfig { + damping: 6.0, + stiffness: 120.0, + mass: 1.0, + duration: Some(0.8), + rest_threshold: None, + }, + ); + assert!( + (at_the_authored_duration - reference).abs() < 1e-9, + "an explicit spring.duration must still drive the rescale, not the (far longer) \ + 10s keyframe span: got {at_the_authored_duration}, expected {reference}" + ); + } +} + #[cfg(test)] mod motion_path_tests { use super::*; diff --git a/crates/rustmotion-core/src/engine/layout_pass.rs b/crates/rustmotion-core/src/engine/layout_pass.rs index e9a03c5e..bb060d52 100644 --- a/crates/rustmotion-core/src/engine/layout_pass.rs +++ b/crates/rustmotion-core/src/engine/layout_pass.rs @@ -60,12 +60,17 @@ impl BoxLayout { #[derive(Debug, Clone, Default)] pub struct LayoutResult { pub layouts: HashMap, + pub resolved_font_sizes_px: HashMap, } impl LayoutResult { pub fn get(&self, id: NodeId) -> Option<&BoxLayout> { self.layouts.get(&id) } + + pub fn font_size(&self, id: NodeId) -> Option { + self.resolved_font_sizes_px.get(&id).copied() + } } /// Per-node user data stored in the taffy tree to keep the link between @@ -84,6 +89,7 @@ struct NodeData { intrinsic: Option>, inset_width: f32, inset_height: f32, + font_size: f32, } /// Run taffy on a [`BoxNode`] tree and return the resolved layouts. @@ -129,9 +135,21 @@ pub fn run_layout(root: &BoxNode, viewport: (f32, f32), ctx: &ConversionContext) // Walk the tree to collect absolute layouts. let mut layouts: HashMap = HashMap::new(); - collect(&tree, root, &node_map, 0.0, 0.0, &mut layouts); + let mut resolved_font_sizes_px: HashMap = HashMap::new(); + collect( + &tree, + root, + &node_map, + 0.0, + 0.0, + &mut layouts, + &mut resolved_font_sizes_px, + ); - LayoutResult { layouts } + LayoutResult { + layouts, + resolved_font_sizes_px, + } } /// Build one taffy node and, recursively, its subtree. @@ -174,6 +192,7 @@ fn build( intrinsic: node.intrinsic.clone(), inset_width, inset_height, + font_size: own_font_size, }; let tf_id = if node.intrinsic.is_some() || node.children.is_empty() { tree.new_leaf_with_context(style, data) @@ -200,6 +219,7 @@ fn collect( parent_x: f32, parent_y: f32, out: &mut HashMap, + out_font_sizes: &mut HashMap, ) { let Some(&tf_id) = map.get(&node.id) else { return; @@ -229,9 +249,12 @@ fn collect( }, }; out.insert(node.id, bx); + if let Some(data) = tree.get_node_context(tf_id) { + out_font_sizes.insert(node.id, data.font_size); + } for c in &node.children { - collect(tree, c, map, abs_x, abs_y, out); + collect(tree, c, map, abs_x, abs_y, out, out_font_sizes); } } @@ -239,7 +262,7 @@ fn collect( mod tests { use super::*; use crate::css::style::*; - use crate::css::units::LengthPercentage; + use crate::css::units::{Length, LengthPercentage}; fn ctx() -> ConversionContext { ConversionContext::default() @@ -381,4 +404,37 @@ mod tests { assert_eq!(child.width, 100.0); assert_eq!(child.height, 80.0); } + + #[test] + fn layout_result_exposes_each_nodes_resolved_font_size() { + let mut root = BoxNode::container( + CssStyle { + font_size: Some(Length::Px(40.0)), + ..Default::default() + }, + vec![BoxNode::container( + CssStyle { + font_size: Some(Length::String("1.5em".into())), + ..Default::default() + }, + vec![], + )], + ); + root.assign_ids(1); + let res = run_layout(&root, (1920.0, 1080.0), &ctx()); + assert_eq!(res.font_size(1), Some(40.0), "root's own font-size"); + assert_eq!( + res.font_size(2), + Some(60.0), + "child's 1.5em must resolve against the parent's cascaded 40px, not the 16px default" + ); + } + + #[test] + fn layout_result_font_size_falls_back_to_inherited_default() { + let mut root = BoxNode::container(CssStyle::default(), vec![]); + root.assign_ids(1); + let res = run_layout(&root, (1920.0, 1080.0), &ctx()); + assert_eq!(res.font_size(1), Some(16.0)); + } } diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 4b93fe75..632ed9c3 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -3,14 +3,13 @@ //! Order per node (top-down): //! 1. canvas.save() //! 2. apply transform (CSS `transform` → Skia matrix) -//! 3. open opacity layer if `opacity < 1.0` -//! 4. clip to padding-box if `overflow != visible` -//! 5. paint outset box-shadow -//! 6. paint background -//! 7. paint border (border-radius aware) -//! 8. delegate component-specific paint via `PaintDispatcher` -//! 9. recurse children sorted by z-index -//! 10. canvas.restore() +//! 3. open opacity/`filter`/`mix-blend-mode` layer, if any is set +//! 4. clip to `clip-path`, if set +//! 5. clip to padding-box if `overflow != visible` +//! 6. paint outset box-shadow, background, border, unless `visibility: hidden` +//! 7. delegate component-specific paint via `PaintDispatcher`, unless `visibility: hidden` +//! 8. recurse children sorted by z-index +//! 9. canvas.restore() //! //! The dispatcher hook lets the higher-level crate plug component-specific //! paint without coupling `rustmotion-core` to all 51 component types. @@ -19,17 +18,17 @@ use std::cell::RefCell; use skia_safe::gradient::{self, Colors as GradientColors, Gradient}; use skia_safe::{ - canvas::SaveLayerRec, Canvas, ClipOp, Color as SColor, Color4f, Paint, PaintStyle, PathBuilder, - Point, RRect, Rect, M44, V3, + canvas::SaveLayerRec, BlendMode as SkBlendMode, Canvas, ClipOp, Color as SColor, Color4f, + Paint, PaintStyle, PathBuilder, Point, RRect, Rect, M44, V3, }; use crate::css::style::{ - Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, Color, - CssStyle, Edges, Overflow, TransformFn, TransformOrigin, + Background, BackgroundLayer, BlendMode, BorderEdges, BorderRadius, BorderStyle, BoxShadow, + ClipPath, Color, CssStyle, Edges, Overflow, TransformFn, TransformOrigin, Visibility, }; use crate::css::units::{parse_origin_component, LengthContext, LengthPercentage, ParsedLength}; use crate::engine::box_tree::{BoxKind, BoxNode, NodeId}; -use crate::engine::layout_pass::{BoxLayout, LayoutResult}; +use crate::engine::layout_pass::{BoxLayout, Insets, LayoutResult}; /// Frame-level paint context (timing + viewport). #[derive(Debug, Clone, Copy)] @@ -134,8 +133,10 @@ pub struct EnrichedHit { pub trait PaintDispatcher { /// Called for `BoxKind::Component(payload)`. Implementations downcast /// `payload` to the concrete component type and paint into `canvas`. - /// The canvas is already translated to the content-box origin and - /// clipped if `overflow: hidden`. + /// `layout` is untranslated (absolute, viewport-space x/y); the canvas + /// is clipped to `overflow: hidden` if set, but `paint_tree` does not + /// translate it to the content-box origin — that translation, if the + /// implementation wants one, is its own responsibility. fn dispatch( &self, canvas: &Canvas, @@ -144,6 +145,11 @@ pub trait PaintDispatcher { layout: &BoxLayout, frame: &PaintFrame, ); + + /// Whether `dispatch` paints `css.background` itself, so the generic background step must skip it. Default `false`. + fn paints_own_background(&self, _payload: &(dyn std::any::Any + Send + Sync)) -> bool { + false + } } /// No-op dispatcher (useful for tests where only generic box decoration is exercised). @@ -222,15 +228,20 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u let Some(box_layout) = ctx.layout.get(node.id) else { return; }; - if box_layout.width <= 0.0 || box_layout.height <= 0.0 { - return; - } + let zero_area_box = box_layout.width <= 0.0 || box_layout.height <= 0.0; + + let payload_opt = match &node.kind { + BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p), + BoxKind::Container => None, + }; + let self_painted_background = + payload_opt.is_some_and(|p| ctx.dispatcher.paints_own_background(p.as_ref())); let length_ctx = LengthContext { viewport_width: ctx.viewport_size.0, viewport_height: ctx.viewport_size.1, parent_size: box_layout.width.max(box_layout.height), - font_size: node.css.font_size_px_or(16.0), + font_size: ctx.layout.font_size(node.id).unwrap_or(16.0), root_font_size: 16.0, }; // Per-axis contexts for `transform`'s translate percentages: CSS @@ -249,6 +260,8 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u ..length_ctx }; + let hidden = matches!(node.css.visibility, Some(Visibility::Hidden)); + canvas.save(); // 1.5 per-plane scene camera (issue #90): every direct child of the scene @@ -339,20 +352,22 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u // 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(); + if !hidden { + 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, 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(); + } } } @@ -384,7 +399,9 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .filter .as_deref() .and_then(|list| filters_to_image_filter(list, &length_ctx)); - let opened_opacity_layer = if opacity < 1.0 || content_filter.is_some() { + let blend_mode = node.css.mix_blend_mode.filter(|m| *m != BlendMode::Normal); + let open_opacity_layer = opacity < 1.0 || content_filter.is_some() || blend_mode.is_some(); + let opened_opacity_layer = if open_opacity_layer { let mut paint = Paint::default(); if opacity < 1.0 { paint.set_alpha((opacity * 255.0) as u8); @@ -392,6 +409,9 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u if let Some(filter) = content_filter { paint.set_image_filter(filter); } + if let Some(mode) = blend_mode { + paint.set_blend_mode(skia_blend_mode(mode)); + } let filter_bleed_px = node .css .filter @@ -411,18 +431,37 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u box_layout.width + bleed * 2.0, box_layout.height + bleed * 2.0, ); + let mut bounded = true; if overflow == Overflow::Visible { - if let Some(descendants) = subtree_layout_bounds(node, ctx.layout) { + if subtree_has_transform(node) { + bounded = false; + } else if let Some(descendants) = subtree_layout_bounds(node, ctx.layout) { bounds = Rect::join2(bounds, descendants); } } - let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds); + let mut rec = SaveLayerRec::default().paint(&paint); + if bounded { + rec = rec.bounds(&bounds); + } canvas.save_layer(&rec); true } else { false }; + let clip_path_shape = node + .css + .clip_path + .as_ref() + .and_then(|cp| build_clip_path(cp, box_layout, &length_ctx)); + let opened_clip_path = if let Some(shape) = &clip_path_shape { + canvas.save(); + canvas.clip_path(shape, ClipOp::Intersect, true); + true + } else { + false + }; + // 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 @@ -431,30 +470,38 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u // 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) { - continue; + if !hidden && !zero_area_box { + if let Some(shadows) = node.css.box_shadow.as_ref() { + for shadow in shadows { + if shadow.inset.unwrap_or(false) { + continue; + } + paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false); } - paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false); } - } - if let Some(bg) = node.css.background.as_ref() { - paint_background(canvas, box_layout, &node.css, bg, &length_ctx); - } - // `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); + if !self_painted_background { + if let Some(bg) = node.css.background.as_ref() { + paint_background(canvas, box_layout, &node.css, bg, &length_ctx); + } + } + // `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); + } } // 7.5. shimmer layer. The band composites against the pixels this node // paints, which do not exist yet — so an isolated layer is opened here, // filled by steps 9 and 10 below, and the band is stamped onto it with // `SrcATop` just before it closes. - let shimmer = active_shimmer(&node.css, ctx.frame.time); + let shimmer = if hidden { + None + } else { + active_shimmer(&node.css, ctx.frame.time) + }; let opened_shimmer_layer = if shimmer.is_some() { let bounds = Rect::from_xywh( box_layout.x, @@ -482,7 +529,7 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .border_radius .as_ref() .map(|r| resolve_border_radius(r, box_layout, &length_ctx)) - .unwrap_or([0.0; 4]); + .unwrap_or([(0.0, 0.0); 4]); let rrect = padding_rrect(box_layout, radius); canvas.save(); canvas.clip_rrect(rrect, ClipOp::Intersect, true); @@ -493,13 +540,11 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u // 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), - BoxKind::Container => None, - }; - if let Some(payload) = payload_opt { - ctx.dispatcher - .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame); + if !hidden && !zero_area_box { + if let Some(payload) = payload_opt { + ctx.dispatcher + .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame); + } } // 10. children (z-index ordered, then source order) @@ -514,10 +559,12 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } // inset shadows (after children so they overlay content) - if let Some(shadows) = node.css.box_shadow.as_ref() { - for shadow in shadows { - if shadow.inset.unwrap_or(false) { - paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, true); + if !hidden && !zero_area_box { + if let Some(shadows) = node.css.box_shadow.as_ref() { + for shadow in shadows { + if shadow.inset.unwrap_or(false) { + paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, true); + } } } } @@ -529,6 +576,10 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u canvas.restore(); } + if opened_clip_path { + canvas.restore(); + } + if opened_opacity_layer { canvas.restore(); } @@ -714,6 +765,17 @@ fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option bounds } +fn subtree_has_transform(node: &BoxNode) -> bool { + node.children.iter().any(|child| { + child + .css + .transform + .as_deref() + .is_some_and(|t| !t.is_empty()) + || subtree_has_transform(child) + }) +} + // ---- CSS filters ---- /// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter` @@ -1242,7 +1304,7 @@ fn paint_background( .border_radius .as_ref() .map(|r| resolve_border_radius(r, layout, ctx)) - .unwrap_or([0.0; 4]); + .unwrap_or([(0.0, 0.0); 4]); let rrect = padding_rrect(layout, radius); match bg { @@ -1389,7 +1451,7 @@ fn paint_border( .border_radius .as_ref() .map(|r| resolve_border_radius(r, layout, ctx)) - .unwrap_or([0.0; 4]); + .unwrap_or([(0.0, 0.0); 4]); // Outer rrect (border box) and inner rrect (padding box). let outer = border_rrect(layout, radius); @@ -1430,7 +1492,7 @@ fn paint_gradient_border( .border_radius .as_ref() .map(|r| resolve_border_radius(r, layout, ctx)) - .unwrap_or([0.0; 4]); + .unwrap_or([(0.0, 0.0); 4]); // Outer ring edge = border box; inner edge = inset by the border width. let outer = border_rrect(layout, radius); @@ -1440,12 +1502,7 @@ fn paint_gradient_border( (layout.width - width * 2.0).max(0.0), (layout.height - width * 2.0).max(0.0), ); - let inner_radius = [ - (radius[0] - width).max(0.0), - (radius[1] - width).max(0.0), - (radius[2] - width).max(0.0), - (radius[3] - width).max(0.0), - ]; + let inner_radius = radius.map(|(rx, ry)| ((rx - width).max(0.0), (ry - width).max(0.0))); let inner = rrect_from_corners(inner_rect, inner_radius); let colors: Vec = gb @@ -1474,45 +1531,67 @@ fn paint_gradient_border( canvas.draw_drrect(outer, inner, &paint); } -fn border_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { +fn border_rrect(layout: &BoxLayout, radius: [(f32, f32); 4]) -> RRect { let rect = Rect::from_xywh(layout.x, layout.y, layout.width, layout.height); rrect_from_corners(rect, radius) } -fn padding_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { +fn padding_rrect(layout: &BoxLayout, radius: [(f32, f32); 4]) -> RRect { let (x, y, w, h) = layout.padding_box(); let rect = Rect::from_xywh(x, y, w, h); - // Inner radius: max(0, outer_radius - border_width). + // Inner radius: max(0, outer_radius - border_width), per axis. let r = [ - (radius[0] - layout.border.left.max(layout.border.top)).max(0.0), - (radius[1] - layout.border.right.max(layout.border.top)).max(0.0), - (radius[2] - layout.border.right.max(layout.border.bottom)).max(0.0), - (radius[3] - layout.border.left.max(layout.border.bottom)).max(0.0), + ( + (radius[0].0 - layout.border.left).max(0.0), + (radius[0].1 - layout.border.top).max(0.0), + ), + ( + (radius[1].0 - layout.border.right).max(0.0), + (radius[1].1 - layout.border.top).max(0.0), + ), + ( + (radius[2].0 - layout.border.right).max(0.0), + (radius[2].1 - layout.border.bottom).max(0.0), + ), + ( + (radius[3].0 - layout.border.left).max(0.0), + (radius[3].1 - layout.border.bottom).max(0.0), + ), ]; rrect_from_corners(rect, r) } -fn inner_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { +fn inner_rrect(layout: &BoxLayout, radius: [(f32, f32); 4]) -> RRect { padding_rrect(layout, radius) } -fn rrect_from_corners(rect: Rect, radius: [f32; 4]) -> RRect { +fn rrect_from_corners(rect: Rect, radius: [(f32, f32); 4]) -> RRect { // Order: top-left, top-right, bottom-right, bottom-left. let radii = [ - Point::new(radius[0], radius[0]), - Point::new(radius[1], radius[1]), - Point::new(radius[2], radius[2]), - Point::new(radius[3], radius[3]), + Point::new(radius[0].0, radius[0].1), + Point::new(radius[1].0, radius[1].1), + Point::new(radius[2].0, radius[2].1), + Point::new(radius[3].0, radius[3].1), ]; RRect::new_rect_radii(rect, &radii) } -fn resolve_border_radius(r: &BorderRadius, layout: &BoxLayout, ctx: &LengthContext) -> [f32; 4] { - let mut local_ctx = *ctx; - local_ctx.parent_size = layout.width.min(layout.height); +fn resolve_border_radius( + r: &BorderRadius, + layout: &BoxLayout, + ctx: &LengthContext, +) -> [(f32, f32); 4] { + let ctx_x = LengthContext { + parent_size: layout.width, + ..*ctx + }; + let ctx_y = LengthContext { + parent_size: layout.height, + ..*ctx + }; match r { BorderRadius::Uniform(v) => { - let p = v.resolve(&local_ctx); + let p = (v.resolve(&ctx_x), v.resolve(&ctx_y)); [p, p, p, p] } BorderRadius::Corners { @@ -1521,14 +1600,133 @@ fn resolve_border_radius(r: &BorderRadius, layout: &BoxLayout, ctx: &LengthConte bottom_right, bottom_left, } => [ - top_left.resolve(&local_ctx), - top_right.resolve(&local_ctx), - bottom_right.resolve(&local_ctx), - bottom_left.resolve(&local_ctx), + (top_left.resolve(&ctx_x), top_left.resolve(&ctx_y)), + (top_right.resolve(&ctx_x), top_right.resolve(&ctx_y)), + (bottom_right.resolve(&ctx_x), bottom_right.resolve(&ctx_y)), + (bottom_left.resolve(&ctx_x), bottom_left.resolve(&ctx_y)), ], } } +fn skia_blend_mode(mode: BlendMode) -> SkBlendMode { + match mode { + BlendMode::Normal => SkBlendMode::SrcOver, + BlendMode::Multiply => SkBlendMode::Multiply, + BlendMode::Screen => SkBlendMode::Screen, + BlendMode::Overlay => SkBlendMode::Overlay, + BlendMode::Darken => SkBlendMode::Darken, + BlendMode::Lighten => SkBlendMode::Lighten, + BlendMode::ColorDodge => SkBlendMode::ColorDodge, + BlendMode::ColorBurn => SkBlendMode::ColorBurn, + BlendMode::HardLight => SkBlendMode::HardLight, + BlendMode::SoftLight => SkBlendMode::SoftLight, + BlendMode::Difference => SkBlendMode::Difference, + BlendMode::Exclusion => SkBlendMode::Exclusion, + BlendMode::Hue => SkBlendMode::Hue, + BlendMode::Saturation => SkBlendMode::Saturation, + BlendMode::Color => SkBlendMode::Color, + BlendMode::Luminosity => SkBlendMode::Luminosity, + BlendMode::PlusLighter => SkBlendMode::Plus, + } +} + +fn build_clip_path( + cp: &ClipPath, + layout: &BoxLayout, + ctx: &LengthContext, +) -> Option { + let ctx_x = LengthContext { + parent_size: layout.width, + ..*ctx + }; + let ctx_y = LengthContext { + parent_size: layout.height, + ..*ctx + }; + match cp { + ClipPath::None => None, + ClipPath::Inset { + top, + right, + bottom, + left, + radius, + } => { + let t = top.resolve(&ctx_y); + let r = right.resolve(&ctx_x); + let b = bottom.resolve(&ctx_y); + let l = left.resolve(&ctx_x); + let rect = Rect::from_xywh( + layout.x + l, + layout.y + t, + (layout.width - l - r).max(0.0), + (layout.height - t - b).max(0.0), + ); + let radii = match radius { + Some(radius) => { + let synth = BoxLayout { + x: rect.left, + y: rect.top, + width: rect.width(), + height: rect.height(), + border: Insets::default(), + padding: Insets::default(), + }; + resolve_border_radius(radius, &synth, ctx) + } + None => [(0.0, 0.0); 4], + }; + Some(skia_safe::Path::rrect( + rrect_from_corners(rect, radii), + None, + )) + } + ClipPath::Circle { radius, origin } => { + let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx); + let circle_radius_basis_ctx = LengthContext { + parent_size: ((layout.width.powi(2) + layout.height.powi(2)) / 2.0).sqrt(), + ..*ctx + }; + let r = radius.resolve(&circle_radius_basis_ctx); + Some(skia_safe::Path::circle(Point::new(cx, cy), r, None)) + } + ClipPath::Ellipse { rx, ry, origin } => { + let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx); + let rxp = rx.resolve(&ctx_x); + let ryp = ry.resolve(&ctx_y); + let rect = Rect::from_xywh(cx - rxp, cy - ryp, rxp * 2.0, ryp * 2.0); + Some(skia_safe::Path::oval(rect, None)) + } + ClipPath::Polygon { points } => { + if points.is_empty() { + return None; + } + let mut pb = PathBuilder::new(); + for (i, (x, y)) in points.iter().enumerate() { + let px = layout.x + x.resolve(&ctx_x); + let py = layout.y + y.resolve(&ctx_y); + if i == 0 { + pb.move_to(Point::new(px, py)); + } else { + pb.line_to(Point::new(px, py)); + } + } + pb.close(); + Some(pb.detach()) + } + ClipPath::Path { d } => { + let raw = skia_safe::Path::from_svg(d)?; + let src = raw.compute_tight_bounds(); + if src.width() <= 0.0 || src.height() <= 0.0 { + return None; + } + let dst = Rect::from_xywh(layout.x, layout.y, layout.width, layout.height); + let matrix = skia_safe::Matrix::rect_2_rect(src, dst, None)?; + Some(raw.with_transform(&matrix)) + } + } +} + // ---- Box-shadow ---- fn paint_box_shadow( @@ -1557,7 +1755,7 @@ fn paint_box_shadow( .border_radius .as_ref() .map(|r| resolve_border_radius(r, layout, ctx)) - .unwrap_or([0.0; 4]); + .unwrap_or([(0.0, 0.0); 4]); let mut paint = Paint::default(); paint.set_anti_alias(true); @@ -2661,6 +2859,31 @@ mod glassmorphism_tests { assert_ne!(top_mid, (255, 255, 255, 255), "top edge must be painted"); } + #[test] + fn border_radius_percent_resolves_per_axis() { + use crate::css::style::BorderRadius; + let node = abs_box( + 100.0, + 100.0, + 400.0, + 120.0, + CssStyle { + border_radius: Some(BorderRadius::Uniform(CLP::String("50%".into()))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + ..Default::default() + }, + ); + let mut root = root_node(600.0, 300.0, Some("#ffffff"), vec![node]); + let buf = render_pixels(&mut root, 600, 300); + + let just_below_top_edge_off_center = px(&buf, 600, 200, 101); + assert_eq!( + just_below_top_edge_off_center, + (255, 255, 255, 255), + "50% border-radius on a non-square box must inscribe an ellipse (per-axis radius), not a circle sized off min(w,h)" + ); + } + #[test] fn gradient_border_replaces_standard_border() { use crate::css::style::{BorderEdges, BorderStyle, Edges}; @@ -2883,7 +3106,7 @@ mod paint_order_tests { use crate::css::style::{ Background, BoxShadow, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection, - Overflow, Position, Size as CSize, + Overflow, Position, Size as CSize, TransformFn, }; use crate::css::taffy_bridge::ConversionContext; use crate::css::units::{Length, LengthPercentage as CLP}; @@ -3092,6 +3315,413 @@ mod paint_order_tests { let far = probe(20, 20); assert_eq!(far, 0, "far corner must stay untouched, got r={far}"); } + + fn parent_with_translated_child(opacity: Option) -> BoxNode { + let child = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + width: Some(CSize::Length(CLP::Px(40.0))), + height: Some(CSize::Length(CLP::Px(40.0))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + transform: Some(vec![TransformFn::TranslateX { x: CLP::Px(200.0) }]), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + 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(60.0))), + height: Some(CSize::Length(CLP::Px(60.0))), + display: Some(Display::Flex), + opacity, + ..Default::default() + }, + children: vec![child], + intrinsic: None, + source_path: None, + window: None, + } + } + + #[test] + fn opacity_layer_bounds_do_not_clip_a_transformed_descendant() { + let probe = |buf: &[u8], x: usize, y: usize| -> (u8, u8, u8) { + let i = (y * 400 + x) * 4; + (buf[i], buf[i + 1], buf[i + 2]) + }; + + let mut plain_root = root_node( + 400.0, + 200.0, + "#000000", + vec![parent_with_translated_child(None)], + ); + let plain = render_pixels(&mut plain_root, 400, 200); + let plain_px = probe(&plain, 270, 70); + assert!( + plain_px.0 > 200 && plain_px.1 < 50, + "sanity: the translated child must land at (270,70) with no opacity layer at all, got {plain_px:?}" + ); + + let mut faded_root = root_node( + 400.0, + 200.0, + "#000000", + vec![parent_with_translated_child(Some(0.999))], + ); + let faded = render_pixels(&mut faded_root, 400, 200); + let faded_px = probe(&faded, 270, 70); + assert!( + faded_px.0 > 200 && faded_px.1 < 50, + "the parent's opacity SaveLayerRec must not clip a child moved outside its own \ + untransformed layout box, got {faded_px:?}" + ); + } +} + +#[cfg(test)] +mod zero_area_tests { + use super::*; + + use crate::css::style::{ + Background, Color as CssColor, CssStyle, Display, FlexDirection, Position, Size as CSize, + }; + use crate::css::taffy_bridge::ConversionContext; + use crate::css::units::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, + scenario_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 + } + + #[test] + fn auto_height_zero_wrapper_still_paints_its_absolute_child() { + let absolute_child = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(20.0)), + top: Some(CLP::Px(20.0)), + width: Some(CSize::Length(CLP::Px(50.0))), + height: Some(CSize::Length(CLP::Px(50.0))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let zero_height_wrapper = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + width: Some(CSize::Length(CLP::Px(200.0))), + ..Default::default() + }, + children: vec![absolute_child], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + width: Some(CSize::Length(CLP::Px(300.0))), + height: Some(CSize::Length(CLP::Px(300.0))), + background: Some(Background::Color(CssColor::String("#000000".into()))), + ..Default::default() + }, + children: vec![zero_height_wrapper], + intrinsic: None, + source_path: None, + window: None, + }; + let buf = render_pixels(&mut root, 300, 300); + + let i = ((45 * 300 + 45) * 4) as usize; + let sample = (buf[i], buf[i + 1], buf[i + 2]); + assert!( + sample.0 > 200 && sample.1 < 50, + "an auto-height wrapper collapsed to 0 by its own layout must still paint an \ + absolutely-positioned child that has its own real size, got {sample:?}" + ); + } +} + +#[cfg(test)] +mod inert_field_tests { + use super::*; + use crate::css::style::{ + Background, BlendMode as CssBlendMode, ClipPath, Color as CssColor, CssStyle, Display, + FlexDirection, Position, Size as CSize, TransformOrigin, Visibility, + }; + use crate::css::taffy_bridge::ConversionContext; + use crate::css::units::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, + scenario_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 probe(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) { + let i = ((y * w + x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]) + } + + fn root_node(w: f32, h: f32, background: Option<&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: background.map(|c| Background::Color(CssColor::String(c.to_string()))), + ..Default::default() + }, + children, + intrinsic: None, + source_path: None, + window: None, + } + } + + fn absolute_box(x: f32, y: f32, w: f32, h: f32, background: &str, extra: CssStyle) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(x)), + top: Some(CLP::Px(y)), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: Some(Background::Color(CssColor::String(background.to_string()))), + ..extra + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + #[test] + fn visibility_hidden_paints_nothing_but_a_visible_child_still_does() { + let child = absolute_box( + 10.0, + 10.0, + 20.0, + 20.0, + "#0000ff", + CssStyle { + visibility: Some(Visibility::Visible), + ..Default::default() + }, + ); + let mut parent = absolute_box( + 0.0, + 0.0, + 100.0, + 100.0, + "#ff0000", + CssStyle { + visibility: Some(Visibility::Hidden), + ..Default::default() + }, + ); + parent.children = vec![child]; + + let mut root = root_node(120.0, 120.0, Some("#000000"), vec![parent]); + let buf = render_pixels(&mut root, 120, 120); + + let point_covered_by_hidden_parent_only = probe(&buf, 120, 90, 90); + assert_eq!( + ( + point_covered_by_hidden_parent_only.0, + point_covered_by_hidden_parent_only.1, + point_covered_by_hidden_parent_only.2 + ), + (0, 0, 0), + "visibility:hidden must not paint its own background, got \ + {point_covered_by_hidden_parent_only:?}" + ); + + let point_covered_by_visible_child = probe(&buf, 120, 20, 20); + assert!( + point_covered_by_visible_child.2 > 200 && point_covered_by_visible_child.0 < 50, + "a `visibility: visible` child under a hidden parent must still paint, got \ + {point_covered_by_visible_child:?}" + ); + } + + #[test] + fn clip_path_circle_clips_the_box_to_a_disc() { + let node = absolute_box( + 0.0, + 0.0, + 100.0, + 100.0, + "#ff0000", + CssStyle { + clip_path: Some(ClipPath::Circle { + radius: CLP::Px(40.0), + origin: Some(TransformOrigin { + x: Some(CLP::Px(50.0)), + y: Some(CLP::Px(50.0)), + ..Default::default() + }), + }), + ..Default::default() + }, + ); + let mut root = root_node(100.0, 100.0, Some("#000000"), vec![node]); + let buf = render_pixels(&mut root, 100, 100); + + let point_inside_the_disc = probe(&buf, 100, 50, 50); + assert!( + point_inside_the_disc.0 > 200 && point_inside_the_disc.1 < 50, + "inside the clip-path circle must still paint, got {point_inside_the_disc:?}" + ); + let point_inside_the_box_but_outside_the_disc = probe(&buf, 100, 5, 5); + assert_eq!( + ( + point_inside_the_box_but_outside_the_disc.0, + point_inside_the_box_but_outside_the_disc.1, + point_inside_the_box_but_outside_the_disc.2 + ), + (0, 0, 0), + "outside the clip-path circle (but inside the box) must not paint, got \ + {point_inside_the_box_but_outside_the_disc:?}" + ); + } + + #[test] + fn mix_blend_mode_multiply_darkens_against_the_backdrop_instead_of_overwriting_it() { + let overlay = |mode: Option| { + absolute_box( + 0.0, + 0.0, + 100.0, + 100.0, + "#ff0000", + CssStyle { + mix_blend_mode: mode, + ..Default::default() + }, + ) + }; + + let normal = { + let mut root = root_node(100.0, 100.0, Some("#808080"), vec![overlay(None)]); + render_pixels(&mut root, 100, 100) + }; + let multiplied = { + let mut root = root_node( + 100.0, + 100.0, + Some("#808080"), + vec![overlay(Some(CssBlendMode::Multiply))], + ); + render_pixels(&mut root, 100, 100) + }; + + let normal_px = probe(&normal, 100, 50, 50); + let multiplied_px = probe(&multiplied, 100, 50, 50); + + assert!( + normal_px.0 > 240 && normal_px.1 < 20, + "sanity: an un-blended opaque overlay must fully overwrite the backdrop, got {normal_px:?}" + ); + assert!( + multiplied_px.0 < 200, + "mix-blend-mode: multiply must darken against the backdrop, got {multiplied_px:?}" + ); + assert_ne!( + multiplied_px.0, normal_px.0, + "multiply must differ from normal blending, both got r={}", + multiplied_px.0 + ); + } } #[cfg(test)] diff --git a/crates/rustmotion-core/src/engine/renderer/assets.rs b/crates/rustmotion-core/src/engine/renderer/assets.rs index 97345599..f0ba08dc 100644 --- a/crates/rustmotion-core/src/engine/renderer/assets.rs +++ b/crates/rustmotion-core/src/engine/renderer/assets.rs @@ -129,10 +129,27 @@ pub fn icon_cache_dir() -> PathBuf { /// Deterministic on-disk file name for a given (icon, color, size). Icon ids /// contain `:` (`"lucide:home"`); replaced so the id survives as a legible /// file name instead of being hashed away. -fn icon_cache_file(cache_dir: &Path, icon: &str, color: &str, width: u32, height: u32) -> PathBuf { +fn icon_cache_file( + cache_dir: &Path, + icon: &str, + color: &str, + width: u32, + height: u32, +) -> Result { let slug = icon.replace(':', "_"); let hex_color = color.trim_start_matches('#').to_lowercase(); - cache_dir.join(format!("{slug}-{hex_color}-{width}x{height}.svg")) + let file_name = format!("{slug}-{hex_color}-{width}x{height}.svg"); + if file_name_escapes_cache_dir(&file_name) { + return Err(RustmotionError::Generic(format!( + "icon '{icon}' (color '{color}') would resolve to cache file name '{file_name}', \ + which escapes the icon cache directory — refusing" + ))); + } + Ok(cache_dir.join(file_name)) +} + +fn file_name_escapes_cache_dir(file_name: &str) -> bool { + file_name.contains('/') || file_name.contains('\\') || file_name.contains('\0') } /// Fetch an icon's SVG bytes, checking the on-disk cache first and falling @@ -162,7 +179,7 @@ pub fn fetch_icon_svg_in( let width = width.max(1); let height = height.max(1); - let cache_file = icon_cache_file(cache_dir, icon, color, width, height); + let cache_file = icon_cache_file(cache_dir, icon, color, width, height)?; if let Ok(data) = std::fs::read(&cache_file) { if !data.is_empty() { return Ok(data); @@ -198,6 +215,17 @@ pub fn fetch_icon_svg_in( Ok(body) } +/// `usvg::Options` that never resolves a plain `` string against the local filesystem. +pub fn sandboxed_svg_options() -> usvg::Options<'static> { + usvg::Options { + image_href_resolver: usvg::ImageHrefResolver { + resolve_string: Box::new(|_href, _opts| None), + ..usvg::ImageHrefResolver::default() + }, + ..usvg::Options::default() + } +} + // ─── Video frame extraction ───────────────────────────────────────────────── /// Cache for pre-extracted video frames: key = "src:width:height", value = sorted list of (time, RGBA data, width, height) @@ -263,7 +291,7 @@ pub fn ffmpeg_available() -> bool { /// doc, a few functions below, and `rustmotion info`'s identical assumption /// both already treat every `src` as a local path — so this closes an /// accidental reach rather than opening an allowlist for one. -fn reject_remote_video_src(src: &str) -> Result<()> { +pub fn reject_remote_video_src(src: &str) -> Result<()> { let Some(scheme_end) = src.find("://") else { return Ok(()); }; @@ -330,10 +358,13 @@ pub fn extract_video_frame(src: &str, time: f64, width: u32, height: u32) -> Res // identified and reported by the *caller* as "remote, not probed" before // any of these functions ever run — probing a remote asset could mean // downloading an unbounded amount of data just to read a header (e.g. a -// large file whose metadata atom sits at the end). These functions are -// written and tested only against local paths on the assumption the -// caller has already filtered URLs out; they do not special-case `http(s) -// ://` themselves. +// large file whose metadata atom sits at the end). `probe_image_dimensions` +// is written and tested only against local paths on the assumption the +// caller has already filtered URLs out. `probe_video_metadata` no longer +// makes that assumption: it rejects a remote `src` itself via +// `reject_remote_video_src`, the same guard `extract_video_frame` uses, +// because it also has direct callers (`video.rs`'s duration probe) that +// never go through `rustmotion info`'s filter. // 2. Cheap when a cheap path exists, honest when it does not. Image // dimensions come from the `image` crate's `into_dimensions()`, which // parses only the header bytes the decoder needs — not a full raster @@ -445,6 +476,8 @@ fn parse_frame_rate(s: &str) -> Option { /// the container's `format.duration` (some containers — notably ones /// produced by streaming muxers — only populate the latter). pub fn probe_video_metadata(src: &str) -> Result { + reject_remote_video_src(src)?; + if !ffprobe_available() { return Err(RustmotionError::Generic(format!( "Cannot read metadata for '{src}': ffprobe not found on PATH. ffprobe ships with \ @@ -463,6 +496,7 @@ pub fn probe_video_metadata(src: &str) -> Result { "-show_format", "-of", "json", + "--", src, ]) .output() @@ -579,7 +613,7 @@ mod tests { let (w, h) = (48, 48); let svg_bytes = b"fake cached icon for the test suite".to_vec(); - let cache_file = icon_cache_file(&cache_dir, icon, color, w, h); + let cache_file = icon_cache_file(&cache_dir, icon, color, w, h).unwrap(); std::fs::write(&cache_file, &svg_bytes).unwrap(); // If this ever fell through to the network, either the test host is @@ -593,9 +627,9 @@ mod tests { #[test] fn disk_cache_is_keyed_by_icon_color_and_size() { let cache_dir = unique_temp_dir("cache-keying"); - let a = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 80, 80); - let b = icon_cache_file(&cache_dir, "lucide:home", "#000000", 80, 80); - let c = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 40, 40); + let a = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 80, 80).unwrap(); + let b = icon_cache_file(&cache_dir, "lucide:home", "#000000", 80, 80).unwrap(); + let c = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 40, 40).unwrap(); assert_ne!(a, b, "different colors must not share a cache file"); assert_ne!(a, c, "different sizes must not share a cache file"); } @@ -610,6 +644,29 @@ mod tests { )); } + #[test] + fn icon_id_with_an_absolute_path_cannot_escape_the_cache_directory() { + let cache_dir = unique_temp_dir("escape-guard-cache"); + let witness_dir = unique_temp_dir("escape-guard-witness"); + let witness_dir_str = witness_dir.to_str().expect("utf8 tempdir path"); + + let icon = format!("{witness_dir_str}/planted:x"); + let color = "#AB"; + let (w, h) = (2, 3); + + let witness_path = PathBuf::from(format!("{witness_dir_str}/planted_x-ab-{w}x{h}.svg")); + let witness_bytes = b"must never be reachable through the icon cache".to_vec(); + std::fs::write(&witness_path, &witness_bytes).unwrap(); + + let result = fetch_icon_svg_in(&icon, color, w, h, &cache_dir); + + assert!( + result.is_err(), + "an icon id that resolves outside cache_dir must be a named, refused error, \ + not a silent read of {witness_path:?} — got {result:?}" + ); + } + // Live network test — mirrors `google_fonts`'s `live_fetch_inter_400`: // excluded from normal runs, exercised manually when touching this path. #[test] @@ -623,7 +680,7 @@ mod tests { let first = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("live fetch"); assert!(!first.is_empty()); - let cache_file = icon_cache_file(&cache_dir, icon, color, w, h); + let cache_file = icon_cache_file(&cache_dir, icon, color, w, h).unwrap(); assert!( cache_file.exists(), "a successful live fetch must be persisted to disk" @@ -789,6 +846,44 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn reject_remote_video_src_allows_local_paths_and_rejects_url_schemes() { + assert!(reject_remote_video_src("assets/clip.mp4").is_ok()); + assert!(reject_remote_video_src("/tmp/clip.mp4").is_ok()); + assert!(reject_remote_video_src("http://example.com/clip.mp4").is_err()); + assert!(reject_remote_video_src("https://example.com/clip.mp4").is_err()); + assert!(reject_remote_video_src("rtmp://example.com/live").is_err()); + } + + #[test] + fn probe_video_metadata_rejects_a_remote_src_before_shelling_out() { + let err = probe_video_metadata("http://127.0.0.1:1/nope.mp4") + .expect_err("a remote src must be rejected, not handed to ffprobe"); + assert!( + err.to_string().contains("does not fetch video"), + "remote src must be rejected before any subprocess is spawned, got: {err}" + ); + } + + #[test] + fn probe_video_metadata_treats_a_dash_prefixed_src_as_a_filename_not_a_flag() { + if !ffprobe_available() { + eprintln!( + "probe_video_metadata_treats_a_dash_prefixed_src_as_a_filename_not_a_flag: \ + ffprobe not found on PATH — skipping" + ); + return; + } + let err = probe_video_metadata("-version").expect_err( + "a dash-prefixed src must be reported as a missing file, not run as an ffprobe flag", + ); + assert!( + err.to_string().contains("could not read"), + "src must reach ffprobe as a bare filename after '--', not be parsed as an ffprobe \ + option — got: {err}" + ); + } + #[test] fn probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic() { if !ffprobe_available() { diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs index 879d86c5..9edf4b94 100644 --- a/crates/rustmotion-core/src/engine/renderer/fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs @@ -1,5 +1,6 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use skia_safe::{FontMgr, FontStyle, Typeface}; @@ -17,6 +18,17 @@ thread_local! { // picked — so each render thread builds each custom face at most once. static CUSTOM_TYPEFACES: RefCell> = RefCell::new(HashMap::new()); + static CUSTOM_TYPEFACES_BUILT_AT_EPOCH: Cell = const { Cell::new(0) }; +} + +static CUSTOM_FONT_REGISTRY_EPOCH: AtomicU64 = AtomicU64::new(0); + +fn evict_custom_typefaces_if_registry_changed_since() { + let current = CUSTOM_FONT_REGISTRY_EPOCH.load(Ordering::Acquire); + if CUSTOM_TYPEFACES_BUILT_AT_EPOCH.with(Cell::get) != current { + CUSTOM_TYPEFACES.with(|cache| cache.borrow_mut().clear()); + CUSTOM_TYPEFACES_BUILT_AT_EPOCH.with(|e| e.set(current)); + } } pub fn font_mgr() -> FontMgr { @@ -51,23 +63,29 @@ fn custom_font_registry() -> &'static Mutex, weight: i32, italic: bool) { - let mut reg = custom_font_registry() - .lock() - .unwrap_or_else(|e| e.into_inner()); - 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, - }); + let mut reg = custom_font_registry() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let variants = reg.entry(family.to_string()).or_default(); + match variants + .iter_mut() + .find(|v| v.weight == weight && v.italic == italic) + { + Some(existing) => existing.data = data, + None => variants.push(CustomFontVariant { + data, + weight, + italic, + }), + } } + CUSTOM_FONT_REGISTRY_EPOCH.fetch_add(1, Ordering::Release); } /// The raw bytes registered for `family`'s closest `(weight, italic)` match, @@ -102,6 +120,7 @@ fn closest_variant( /// 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 { + evict_custom_typefaces_if_registry_changed_since(); let weight = *style.weight(); let italic = style.slant() != skia_safe::font_style::Slant::Upright; let cache_key = (family.to_string(), weight, italic); @@ -240,11 +259,55 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) } } +thread_local! { + static SYSTEM_TYPEFACE_CACHE: RefCell>> = + RefCell::new(HashMap::new()); +} + +#[cfg(test)] +thread_local! { + static SYSTEM_TYPEFACE_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_system_typeface_probe() { + SYSTEM_TYPEFACE_LOOKUPS.with(|c| c.set(0)); + SYSTEM_TYPEFACE_CACHE.with(|c| c.borrow_mut().clear()); +} + +#[cfg(test)] +pub(crate) fn system_typeface_lookups() -> u64 { + SYSTEM_TYPEFACE_LOOKUPS.with(|c| c.get()) +} + +fn system_typeface_cached(family: &str, style: FontStyle) -> Option { + let weight = *style.weight(); + let italic = style.slant() != skia_safe::font_style::Slant::Upright; + let key = (family.to_string(), weight, italic); + if let Some(hit) = SYSTEM_TYPEFACE_CACHE.with(|c| c.borrow().get(&key).cloned()) { + return hit; + } + #[cfg(test)] + SYSTEM_TYPEFACE_LOOKUPS.with(|c| c.set(c.get() + 1)); + let resolved = font_mgr().match_family_style(family, style); + SYSTEM_TYPEFACE_CACHE.with(|c| c.borrow_mut().insert(key, resolved.clone())); + resolved +} + /// Resolve a typeface for `family` falling back through Helvetica → Arial → /// the OS default. Returns `RustmotionError::FontNotFound` only if the host /// system has no usable font at all (essentially unreachable on every /// supported platform). Use this instead of `.expect("FontNotFound")` so we /// never panic from a `paint` callback. +fn first_sighting_of_missing_family(family: &str) -> bool { + use std::collections::HashSet; + static SEEN: OnceLock>> = OnceLock::new(); + SEEN.get_or_init(Default::default) + .lock() + .map(|mut seen| seen.insert(family.to_owned())) + .unwrap_or(false) +} + 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 @@ -254,17 +317,24 @@ pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result Result> { std::fs::create_dir_all(cache_dir).map_err(RustmotionError::Io)?; - let slug = family_slug(family); let mut paths = Vec::with_capacity(weights.len()); let mut missing_weights: Vec = Vec::new(); // Check which weights are already cached. for &weight in weights { - let dest = cache_dir.join(format!("{slug}-{weight}.ttf")); + let dest = font_cache_file(cache_dir, family, weight)?; if dest.exists() { paths.push(dest); } else { @@ -98,7 +97,7 @@ pub fn resolve_google_font( let ttf_url = ttf_urls .get(i) .unwrap_or_else(|| &ttf_urls[ttf_urls.len() - 1]); - let dest = cache_dir.join(format!("{slug}-{weight}.ttf")); + let dest = font_cache_file(cache_dir, family, weight)?; fetch_ttf(ttf_url, &dest, family, weight)?; paths.push(dest); } @@ -183,6 +182,22 @@ pub fn family_slug(family: &str) -> String { family.to_lowercase().replace(' ', "-") } +fn font_cache_file(cache_dir: &Path, family: &str, weight: u16) -> Result { + let slug = family_slug(family); + let file_name = format!("{slug}-{weight}.ttf"); + if file_name_escapes_cache_dir(&file_name) { + return Err(RustmotionError::Generic(format!( + "font family '{family}' would resolve to cache file name '{file_name}', \ + which escapes the font cache directory — refusing" + ))); + } + Ok(cache_dir.join(file_name)) +} + +fn file_name_escapes_cache_dir(file_name: &str) -> bool { + file_name.contains('/') || file_name.contains('\\') || file_name.contains('\0') +} + // ─── Network helpers ───────────────────────────────────────────────────────── fn fetch_css2(url: &str, family: &str) -> Result { @@ -351,6 +366,32 @@ mod tests { assert_eq!(paths[0], pre_placed); } + #[test] + fn family_name_with_an_absolute_path_cannot_escape_the_cache_directory() { + let cache_dir = make_test_cache("escape-guard-cache"); + let witness_dir = make_test_cache("escape-guard-witness"); + let witness_dir_str = witness_dir.to_str().expect("utf8 tempdir path"); + + let family = format!("{witness_dir_str}/planted"); + let weight = 400u16; + + let slug = family_slug(&family); + let witness_path = PathBuf::from(format!("{slug}-{weight}.ttf")); + std::fs::write( + &witness_path, + b"must never be reachable through the font cache", + ) + .unwrap(); + + let result = resolve_google_font(&family, &[weight], &cache_dir); + + assert!( + result.is_err(), + "a font family that resolves outside cache_dir must be a named, refused error, \ + not a silent hit on {witness_path:?} — got {result:?}" + ); + } + #[test] fn cache_hit_multi_weight_all_present() { let cache_dir = make_test_cache("cache-hit-multi"); diff --git a/crates/rustmotion-core/src/engine/renderer/shapes.rs b/crates/rustmotion-core/src/engine/renderer/shapes.rs index 48156aa2..3b143ec2 100644 --- a/crates/rustmotion-core/src/engine/renderer/shapes.rs +++ b/crates/rustmotion-core/src/engine/renderer/shapes.rs @@ -85,10 +85,21 @@ pub fn build_shape_path( path.close(); Some(path.detach()) } - ShapeType::Path { data } => skia_safe::Path::from_svg(data), + ShapeType::Path { data } => fit_svg_path_to_box(data, x, y, w, h), } } +fn fit_svg_path_to_box(data: &str, x: f32, y: f32, w: f32, h: f32) -> Option { + let raw = skia_safe::Path::from_svg(data)?; + let src = raw.compute_tight_bounds(); + if src.width() <= 0.0 || src.height() <= 0.0 { + return None; + } + let dst = Rect::from_xywh(x, y, w, h); + let matrix = skia_safe::Matrix::rect_2_rect(src, dst, None)?; + Some(raw.with_transform(&matrix)) +} + pub fn draw_shape_path( canvas: &Canvas, shape_type: &ShapeType, @@ -167,9 +178,48 @@ pub fn draw_shape_path( canvas.draw_path(&path.detach(), paint); } ShapeType::Path { data } => { - if let Some(path) = skia_safe::Path::from_svg(data) { + if let Some(path) = fit_svg_path_to_box(data, x, y, w, h) { canvas.draw_path(&path, paint); } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_path_shape_is_fit_to_its_declared_box_not_drawn_at_raw_svg_coordinates() { + let data = "M300 200 L399 200 L399 269 L300 269 Z"; + let path = build_shape_path( + &ShapeType::Path { data: data.into() }, + 10.0, + 10.0, + 40.0, + 20.0, + None, + ) + .expect("a well-formed SVG path must parse"); + let b = path.compute_tight_bounds(); + assert!( + b.left >= 9.5 && b.top >= 9.5 && b.right <= 50.5 && b.bottom <= 30.5, + "expected the path fit inside (10,10)-(50,30), got {:?}", + b + ); + } + + #[test] + fn a_degenerate_path_returns_none_instead_of_panicking() { + let data = "M5 5 L5 5 Z"; + let path = build_shape_path( + &ShapeType::Path { data: data.into() }, + 0.0, + 0.0, + 40.0, + 20.0, + None, + ); + assert!(path.is_none()); + } +} diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs index 6d578977..5d4e789d 100644 --- a/crates/rustmotion-core/src/engine/renderer/text.rs +++ b/crates/rustmotion-core/src/engine/renderer/text.rs @@ -1,3 +1,6 @@ +use std::cell::RefCell; +use std::collections::HashMap; + use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface}; // ─── Counter formatting ───────────────────────────────────────────────────── @@ -60,6 +63,54 @@ pub fn format_counter_value( // ─── Text utilities ───────────────────────────────────────────────────────── +thread_local! { + static STR_WIDTH_CACHE: RefCell> = + RefCell::new(HashMap::new()); +} + +const STR_WIDTH_CACHE_CAP: usize = 8192; + +#[cfg(test)] +thread_local! { + static MEASURED_CHARS_PROBE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_str_width_probe() { + MEASURED_CHARS_PROBE.with(|c| c.set(0)); + STR_WIDTH_CACHE.with(|c| c.borrow_mut().clear()); +} + +#[cfg(test)] +pub(crate) fn measured_chars_probe() -> u64 { + MEASURED_CHARS_PROBE.with(|c| c.get()) +} + +fn measure_str_cached(font: &Font, text: &str) -> f32 { + if text.is_empty() { + return 0.0; + } + let key = ( + font.typeface().unique_id(), + font.size().to_bits(), + text.to_string(), + ); + if let Some(w) = STR_WIDTH_CACHE.with(|c| c.borrow().get(&key).copied()) { + return w; + } + #[cfg(test)] + MEASURED_CHARS_PROBE.with(|c| c.set(c.get() + text.chars().count() as u64)); + let (w, _) = font.measure_str(text, None); + STR_WIDTH_CACHE.with(|c| { + let mut cache = c.borrow_mut(); + if cache.len() >= STR_WIDTH_CACHE_CAP { + cache.clear(); + } + cache.insert(key, w); + }); + w +} + pub fn wrap_text(text: &str, font: &Font, max_width: Option) -> Vec { let explicit_lines: Vec<&str> = text.split('\n').collect(); @@ -68,6 +119,7 @@ pub fn wrap_text(text: &str, font: &Font, max_width: Option) -> Vec None => return explicit_lines.iter().map(|s| s.to_string()).collect(), }; + let space_w = measure_str_cached(font, " "); let mut result = Vec::new(); for line in explicit_lines { let words: Vec<&str> = line.split_whitespace().collect(); @@ -77,19 +129,25 @@ pub fn wrap_text(text: &str, font: &Font, max_width: Option) -> Vec } let mut current_line = String::new(); + let mut current_w = 0.0f32; for word in words { - let test = if current_line.is_empty() { - word.to_string() + let word_w = measure_str_cached(font, word); + let test_w = if current_line.is_empty() { + word_w } else { - format!("{} {}", current_line, word) + current_w + space_w + word_w }; - let (width, _) = font.measure_str(&test, None); - if width > max_w && !current_line.is_empty() { - result.push(current_line); - current_line = word.to_string(); + if test_w > max_w && !current_line.is_empty() { + result.push(std::mem::take(&mut current_line)); + current_line.push_str(word); + current_w = word_w; } else { - current_line = test; + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(word); + current_w = test_w; } } if !current_line.is_empty() { @@ -433,7 +491,7 @@ pub fn draw_text_with_fallback( } // Advance cursor by the measured width of this run - let (w, _) = f.measure_str(segment, None); + let w = measure_str_cached(f, segment); let extra = if letter_spacing.abs() > 0.01 { letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0) } else { @@ -454,7 +512,7 @@ pub fn measure_text_with_fallback( ) -> f32 { // Fast path if !needs_segmentation(text, font, emoji_font) { - let (w, _) = font.measure_str(text, None); + let w = measure_str_cached(font, text); let extra = if letter_spacing.abs() > 0.01 { letter_spacing * (text.chars().count() as f32 - 1.0).max(0.0) } else { @@ -470,7 +528,7 @@ pub fn measure_text_with_fallback( let segment = &text[run.start..run.end]; 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 w = measure_str_cached(f, segment); let extra = if letter_spacing.abs() > 0.01 { letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0) } else { @@ -528,27 +586,102 @@ pub fn wrap_text_with_tracking( continue; } - let mut current_line = String::new(); - for word in words { - let test = if current_line.is_empty() { - word.to_string() - } else { - format!("{} {}", current_line, word) - }; - - let width = measure_text_with_fallback(&test, font, emoji_font, letter_spacing); - if width > max_w && !current_line.is_empty() { - result.push(current_line); - current_line = word.to_string(); - } else { - current_line = test; - } + if needs_segmentation(line, font, emoji_font) { + wrap_words_by_full_remeasurement( + &words, + font, + emoji_font, + letter_spacing, + max_w, + &mut result, + ); + } else { + wrap_words_incrementally(&words, font, letter_spacing, max_w, &mut result); } - if !current_line.is_empty() { + } + result +} + +fn wrap_words_by_full_remeasurement( + words: &[&str], + font: &Font, + emoji_font: &Option, + letter_spacing: f32, + max_w: f32, + result: &mut Vec, +) { + let mut current_line = String::new(); + for &word in words { + let test = if current_line.is_empty() { + word.to_string() + } else { + format!("{} {}", current_line, word) + }; + + let width = measure_text_with_fallback(&test, font, emoji_font, letter_spacing); + if width > max_w && !current_line.is_empty() { result.push(current_line); + current_line = word.to_string(); + } else { + current_line = test; } } - result + if !current_line.is_empty() { + result.push(current_line); + } +} + +fn wrap_words_incrementally( + words: &[&str], + font: &Font, + letter_spacing: f32, + max_w: f32, + result: &mut Vec, +) { + let space_w = measure_str_cached(font, " "); + let tracking_extra = |chars: usize| { + if letter_spacing.abs() > 0.01 { + letter_spacing * (chars as f32 - 1.0).max(0.0) + } else { + 0.0 + } + }; + + let mut current_line = String::new(); + let mut current_base_w = 0.0f32; + let mut current_chars = 0usize; + + for &word in words { + let word_base_w = measure_str_cached(font, word); + let word_chars = word.chars().count(); + + let (test_base_w, test_chars) = if current_line.is_empty() { + (word_base_w, word_chars) + } else { + ( + current_base_w + space_w + word_base_w, + current_chars + 1 + word_chars, + ) + }; + let test_w = test_base_w + tracking_extra(test_chars); + + if test_w > max_w && !current_line.is_empty() { + result.push(std::mem::take(&mut current_line)); + current_line.push_str(word); + current_base_w = word_base_w; + current_chars = word_chars; + } else { + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(word); + current_base_w = test_base_w; + current_chars = test_chars; + } + } + if !current_line.is_empty() { + result.push(current_line); + } } /// Wrap text respecting emoji font fallback for accurate measurement. @@ -1201,3 +1334,197 @@ mod glyph_fallback_tests { ); } } + +#[cfg(test)] +mod measure_cache_tests { + use super::super::typeface_with_fallback; + use super::*; + + fn bold_font(size: f32) -> Font { + let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::bold()) + .expect("host must have a fallback typeface"); + Font::from_typeface(typeface, size) + } + + #[test] + fn measure_text_with_fallback_reuses_the_cache_for_repeated_identical_calls() { + reset_str_width_probe(); + let font = bold_font(48.0); + let text = "Revenue is trending up this quarter"; + + let w1 = measure_text_with_fallback(text, &font, &None, 0.0); + let after_first = measured_chars_probe(); + assert_eq!( + after_first, + text.chars().count() as u64, + "the first call must pay for exactly one underlying Skia measurement" + ); + + for _ in 0..5 { + let w = measure_text_with_fallback(text, &font, &None, 0.0); + assert_eq!(w, w1, "a cache hit must return the exact same width"); + } + assert_eq!( + measured_chars_probe(), + after_first, + "5 repeated identical (font, size, tracking, text) calls must not call into Skia \ + again — measure_text_with_fallback has no cache" + ); + } + + #[test] + fn measure_text_with_fallback_still_measures_a_genuinely_new_string() { + reset_str_width_probe(); + let font = bold_font(48.0); + measure_text_with_fallback("first string", &font, &None, 0.0); + let after_first = measured_chars_probe(); + measure_text_with_fallback("a different second string", &font, &None, 0.0); + assert!( + measured_chars_probe() > after_first, + "a string that was never measured before must still reach Skia" + ); + } + + #[test] + fn measure_text_with_fallback_distinguishes_font_size() { + reset_str_width_probe(); + let text = "same text, different size"; + measure_text_with_fallback(text, &bold_font(24.0), &None, 0.0); + let after_first = measured_chars_probe(); + let w_big = measure_text_with_fallback(text, &bold_font(96.0), &None, 0.0); + assert!( + measured_chars_probe() > after_first, + "a different font size must not reuse another size's cached width" + ); + assert!( + w_big > 0.0, + "sanity: the larger font must still produce a real measurement" + ); + } +} + +#[cfg(test)] +mod wrap_complexity_tests { + use super::super::typeface_with_fallback; + use super::*; + + fn plain_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) + } + + fn distinct_words(n: usize) -> String { + (0..n) + .map(|i| format!("word{i:04}xx")) + .collect::>() + .join(" ") + } + + #[test] + fn wrap_text_with_tracking_measures_linearly_in_word_count() { + reset_str_width_probe(); + let font = plain_font(14.0); + let text = distinct_words(200); + let total_len = text.chars().count() as u64; + + let lines = wrap_text_with_tracking(&text, &font, &None, Some(1_000_000.0), 0.0); + assert_eq!(lines.len(), 1, "test setup: an effectively unbounded width must keep every word on one line, so every word's measurement re-measures the whole growing prefix under the pre-fix algorithm"); + + let measured = measured_chars_probe(); + assert!( + measured <= total_len * 4, + "wrap_text_with_tracking measured {measured} characters' worth of text for a {total_len}-\ + character input — expected roughly linear growth (<= {}), got what looks like the \ + quadratic re-measure-the-whole-prefix-per-word behaviour", + total_len * 4 + ); + } + + #[test] + fn wrap_text_measures_linearly_in_word_count() { + reset_str_width_probe(); + let font = plain_font(14.0); + let text = distinct_words(200); + let total_len = text.chars().count() as u64; + + let lines = wrap_text(&text, &font, Some(1_000_000.0)); + assert_eq!(lines.len(), 1, "test setup: an effectively unbounded width must keep every word on one line, so every word's measurement re-measures the whole growing prefix under the pre-fix algorithm"); + + let measured = measured_chars_probe(); + assert!( + measured <= total_len * 4, + "wrap_text measured {measured} characters' worth of text for a {total_len}-character \ + input — expected roughly linear growth (<= {}), got what looks like the quadratic \ + re-measure-the-whole-prefix-per-word behaviour", + total_len * 4 + ); + } + + fn brute_force_remeasurement_wrap(font: &Font, words: &[&str], max_w: f32) -> Vec { + let mut result = Vec::new(); + let mut current_line = String::new(); + for &word in words { + let test = if current_line.is_empty() { + word.to_string() + } else { + format!("{current_line} {word}") + }; + let (width, _) = font.measure_str(&test, None); + if width > max_w && !current_line.is_empty() { + result.push(current_line); + current_line = word.to_string(); + } else { + current_line = test; + } + } + if !current_line.is_empty() { + result.push(current_line); + } + result + } + + #[test] + fn wrap_text_agrees_with_brute_force_remeasurement() { + let font = plain_font(18.0); + let repeated_word = "word ".repeat(37); + let cases: &[(&str, f32)] = &[ + ("The quick brown fox jumps over the lazy dog", 220.0), + (repeated_word.trim(), 150.0), + ("a bb ccc dddd eeeee ffffff ggggggg", 90.0), + ("single", 500.0), + ("exactly one line of text that just fits", 10_000.0), + ]; + for &(text, max_w) in cases { + let words: Vec<&str> = text.split_whitespace().collect(); + let expected = brute_force_remeasurement_wrap(&font, &words, max_w); + let actual = wrap_text(text, &font, Some(max_w)); + assert_eq!( + actual, expected, + "incremental wrap_text disagreed with brute-force remeasurement for {text:?} at \ + max_w={max_w}" + ); + } + } + + #[test] + fn wrap_text_with_tracking_agrees_with_brute_force_remeasurement_at_zero_tracking() { + let font = plain_font(18.0); + let repeated_word = "word ".repeat(37); + let cases: &[(&str, f32)] = &[ + ("The quick brown fox jumps over the lazy dog", 220.0), + (repeated_word.trim(), 150.0), + ("a bb ccc dddd eeeee ffffff ggggggg", 90.0), + ]; + for &(text, max_w) in cases { + let words: Vec<&str> = text.split_whitespace().collect(); + let expected = brute_force_remeasurement_wrap(&font, &words, max_w); + let actual = wrap_text_with_tracking(text, &font, &None, Some(max_w), 0.0); + assert_eq!( + actual, expected, + "incremental wrap_text_with_tracking disagreed with brute-force remeasurement \ + for {text:?} at max_w={max_w}" + ); + } + } +} diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 22e5923f..88c18999 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -25,6 +25,7 @@ pub struct TransitionOptions { pub direction: TransitionDirection, /// `chromatic_wipe`: channel-split multiplier. pub aberration: f32, + pub clear_color: [f32; 4], } impl Default for TransitionOptions { @@ -36,6 +37,7 @@ impl Default for TransitionOptions { order: PixelDissolveOrder::default(), direction: TransitionDirection::default(), aberration: 1.0, + clear_color: [0.0, 0.0, 0.0, 1.0], } } } @@ -49,6 +51,7 @@ impl From<&Transition> for TransitionOptions { order: t.order, direction: t.direction, aberration: t.aberration, + clear_color: TransitionOptions::default().clear_color, } } } @@ -72,6 +75,7 @@ pub fn apply_transition( order, direction, aberration, + clear_color, } = *opts; match transition_type { @@ -90,7 +94,9 @@ pub fn apply_transition( TransitionType::ZoomOut => { zoom_transition(frame_a, frame_b, width, height, progress, false) } - TransitionType::Flip => flip_transition(frame_a, frame_b, width, height, progress), + TransitionType::Flip => { + flip_transition(frame_a, frame_b, width, height, progress, clear_color) + } TransitionType::ClockWipe => clock_wipe(frame_a, frame_b, width, height, progress), TransitionType::Iris => iris_transition(frame_a, frame_b, width, height, progress), TransitionType::Slide => slide_transition(frame_a, frame_b, width, height, progress), @@ -452,6 +458,7 @@ fn flip_transition( width: u32, height: u32, progress: f32, + clear_color: [f32; 4], ) -> Vec { let mut surface = match create_skia_surface(width, height) { Some(s) => s, @@ -468,12 +475,18 @@ fn flip_transition( let canvas = surface.canvas(); let w = width as f32; + let clear = Color4f::new( + clear_color[0], + clear_color[1], + clear_color[2], + clear_color[3], + ); // Simulate 3D flip by scaling X axis // First half: frame_a shrinks on X. Second half: frame_b grows on X. if progress < 0.5 { let scale_x = 1.0 - progress * 2.0; // 1.0 -> 0.0 - canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); + canvas.clear(clear); canvas.save(); canvas.translate((w / 2.0, 0.0)); canvas.scale((scale_x.max(0.01), 1.0)); @@ -482,7 +495,7 @@ fn flip_transition( canvas.restore(); } else { let scale_x = (progress - 0.5) * 2.0; // 0.0 -> 1.0 - canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); + canvas.clear(clear); canvas.save(); canvas.translate((w / 2.0, 0.0)); canvas.scale((scale_x.max(0.01), 1.0)); @@ -495,6 +508,9 @@ fn flip_transition( } fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress: f32) -> Vec { + if progress >= 1.0 { + return frame_b.to_vec(); + } let mut surface = match create_skia_surface(width, height) { Some(s) => s, None => return blend_fade(frame_a, frame_b, progress), @@ -864,6 +880,65 @@ fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) -> Some(surface_to_pixels(surface, width, height)) } +#[cfg(test)] +mod flip_tests { + use super::*; + + fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec { + (0..width * height).flat_map(|_| [r, g, b, a]).collect() + } + + #[test] + fn the_clear_behind_the_edge_on_frame_follows_the_configured_color() { + let (w, h) = (16u32, 16u32); + let frame_a = solid(w, h, 255, 0, 0, 255); + let frame_b = solid(w, h, 0, 0, 255, 255); + let white = [1.0, 1.0, 1.0, 1.0]; + + let out = flip_transition(&frame_a, &frame_b, w, h, 0.49, white); + let white_pixels = out + .as_chunks::<4>() + .0 + .iter() + .filter(|px| px[0] > 250 && px[1] > 250 && px[2] > 250) + .count(); + let total = (w * h) as usize; + assert!( + white_pixels as f32 / total as f32 > 0.9, + "a near-edge-on frame should mostly show the configured clear colour: \ + {white_pixels}/{total} white" + ); + } + + #[test] + fn the_default_clear_stays_black() { + let (w, h) = (16u32, 16u32); + let frame_a = solid(w, h, 255, 0, 0, 255); + let frame_b = solid(w, h, 0, 0, 255, 255); + + let out = flip_transition( + &frame_a, + &frame_b, + w, + h, + 0.49, + TransitionOptions::default().clear_color, + ); + let black_pixels = out + .as_chunks::<4>() + .0 + .iter() + .filter(|px| px[0] < 5 && px[1] < 5 && px[2] < 5) + .count(); + let total = (w * h) as usize; + assert!( + black_pixels as f32 / total as f32 > 0.9, + "the default clear colour must stay black (unchanged behaviour): \ + {black_pixels}/{total} black" + ); + } +} + #[cfg(test)] mod camera_pan_tests { use super::*; @@ -1256,3 +1331,55 @@ mod pixel_dissolve_tests { } } } + +#[cfg(test)] +mod clock_wipe_tests { + use super::*; + + fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec { + (0..width * height).flat_map(|_| [r, g, b, a]).collect() + } + + #[test] + fn the_last_frame_is_pure_frame_b_not_a_flash_of_frame_a() { + let (w, h) = (8u32, 8u32); + let frame_a = solid(w, h, 255, 0, 0, 255); + let frame_b = solid(w, h, 0, 0, 255, 255); + + let out = clock_wipe(&frame_a, &frame_b, w, h, 1.0); + assert_eq!( + out, frame_b, + "progress=1.0 must land exactly on frame_b, not revert to frame_a" + ); + } + + #[test] + fn a_near_complete_sweep_is_mostly_frame_b() { + let (w, h) = (32u32, 32u32); + let frame_a = solid(w, h, 255, 0, 0, 255); + let frame_b = solid(w, h, 0, 0, 255, 255); + + let out = clock_wipe(&frame_a, &frame_b, w, h, 0.97); + let blue_pixels = out + .as_chunks::<4>() + .0 + .iter() + .filter(|px| px[2] > px[0]) + .count(); + let total = (w * h) as usize; + assert!( + blue_pixels as f32 / total as f32 > 0.8, + "a 349-degree sweep should already cover most of the frame: {blue_pixels}/{total}" + ); + } + + #[test] + fn the_first_frame_is_pure_frame_a() { + let (w, h) = (8u32, 8u32); + let frame_a = solid(w, h, 255, 0, 0, 255); + let frame_b = solid(w, h, 0, 0, 255, 255); + + let out = clock_wipe(&frame_a, &frame_b, w, h, 0.0); + assert_eq!(out, frame_a); + } +} diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index a86ff45e..9dd6027d 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -188,6 +188,14 @@ pub enum RustmotionError { path: String, }, + #[error("Parameter '{param}' of component '{component}' at '{path}' {detail}")] + ComponentParamTypeMismatch { + component: String, + param: String, + path: String, + detail: String, + }, + #[error("Component instantiation cycle at '{path}': {chain}")] ComponentCycle { chain: String, path: String }, diff --git a/crates/rustmotion-core/src/expand.rs b/crates/rustmotion-core/src/expand.rs index c47860e1..ad744d67 100644 --- a/crates/rustmotion-core/src/expand.rs +++ b/crates/rustmotion-core/src/expand.rs @@ -60,9 +60,10 @@ //! to one component instead of the whole file. `use` + its overrides field //! mirrors `IncludeDirective { include, config }` (a name plus overrides) — //! *except* the overrides field is called **`props`**, not `config`. That is -//! a deliberate, load-bearing difference, not inconsistency: [`substitute`] -//! (shared with `variables.rs`) skips recursing into any object key literally -//! named `"config"`, so that the scenario-level `config` *declarations* block +//! a deliberate, load-bearing difference, not inconsistency: +//! [`crate::variables::substitute_directive_bindings`] skips recursing into +//! any object key literally named `"config"`, so that the scenario-level +//! `config` *declarations* block //! (whose `default` values must stay literal, see //! `variables::test_config_key_not_substituted`) is never accidentally //! rewritten by whole-document substitution. Reusing that same key name for @@ -147,7 +148,7 @@ use serde_json::Value; use crate::error::{Result, RustmotionError}; use crate::schema::VariableType; -use crate::variables::substitute; +use crate::variables::substitute_directive_bindings; /// Defense-in-depth ceiling on nested `use`/`for-each` expansion. True /// self-reference cycles are caught immediately by the name stack in @@ -195,8 +196,6 @@ struct ComponentDefinition { #[serde(deny_unknown_fields)] struct ComponentParam { #[serde(rename = "type")] - #[allow(dead_code)] - // documentation/schema parity with `config`; not cross-checked against `default`'s actual JSON type (same as `VariableDefinition::var_type` today) param_type: VariableType, #[serde(default)] default: Option, @@ -248,6 +247,7 @@ fn is_use(v: &Value) -> bool { pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> { let defs = extract_component_definitions(value, file_label)?; let mut budget = MAX_EXPANSION_NODES; + let root_scope: HashMap = HashMap::new(); let Value::Object(root) = value else { return Ok(()); @@ -267,6 +267,7 @@ pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> { &mut stack, 0, &mut budget, + &root_scope, )?; out.push(scene); } @@ -290,6 +291,7 @@ pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> { &mut stack, 0, &mut budget, + &root_scope, )?; out.push(scene); } @@ -393,6 +395,7 @@ fn walk_children( stack: &mut Vec, depth: u32, budget: &mut u64, + scope: &HashMap, ) -> Result<()> { match value { Value::Object(map) => { @@ -402,7 +405,7 @@ fn walk_children( for (i, entry) in arr.into_iter().enumerate() { let entry_loc = format!("{location}.children[{i}]"); expanded.extend(resolve_entry( - entry, defs, file_label, &entry_loc, stack, depth, budget, + entry, defs, file_label, &entry_loc, stack, depth, budget, scope, )?); } map.insert("children".to_string(), Value::Array(expanded)); @@ -412,12 +415,12 @@ fn walk_children( if k == "children" { continue; } - walk_children(v, defs, file_label, location, stack, depth, budget)?; + walk_children(v, defs, file_label, location, stack, depth, budget, scope)?; } } Value::Array(arr) => { for v in arr.iter_mut() { - walk_children(v, defs, file_label, location, stack, depth, budget)?; + walk_children(v, defs, file_label, location, stack, depth, budget, scope)?; } } _ => {} @@ -447,6 +450,7 @@ fn resolve_entry( stack: &mut Vec, depth: u32, budget: &mut u64, + scope: &HashMap, ) -> Result> { if depth > MAX_EXPANSION_DEPTH { return Err(RustmotionError::ExpansionDepthExceeded { @@ -467,31 +471,21 @@ fn resolve_entry( stack, depth + 1, budget, + scope, )?); } return Ok(out); } if is_for_each(&entry) { - let produced = expand_for_each_directive(entry, file_label, location, budget)?; - let mut out = Vec::with_capacity(produced.len()); - for (i, node) in produced.into_iter().enumerate() { - let iter_loc = format!("{location}[{i}]"); - out.extend(resolve_entry( - node, - defs, - file_label, - &iter_loc, - stack, - depth + 1, - budget, - )?); - } - return Ok(out); + return expand_for_each_directive( + entry, defs, file_label, location, stack, depth, budget, scope, + ); } if is_use(&entry) { - let (name, node) = expand_use_directive(entry, defs, file_label, location)?; + let (name, node, use_scope) = + expand_use_directive(entry, defs, file_label, location, scope)?; if stack.contains(&name) { let mut chain = stack.clone(); chain.push(name); @@ -501,27 +495,43 @@ fn resolve_entry( }); } stack.push(name); - let result = resolve_entry(node, defs, file_label, location, stack, depth + 1, budget); + let result = resolve_entry( + node, + defs, + file_label, + location, + stack, + depth + 1, + budget, + &use_scope, + ); stack.pop(); return result; } let mut node = entry; - walk_children(&mut node, defs, file_label, location, stack, depth, budget)?; + walk_children( + &mut node, defs, file_label, location, stack, depth, budget, scope, + )?; Ok(vec![node]) } fn expand_for_each_directive( entry: Value, + defs: &HashMap, file_label: &str, location: &str, + stack: &mut Vec, + depth: u32, budget: &mut u64, + scope: &HashMap, ) -> Result> { - let directive: ForEachDirective = + let mut directive: ForEachDirective = serde_json::from_value(entry).map_err(|e| RustmotionError::ForEachDirectiveInvalid { path: format!("{file_label}: {location}"), reason: e.to_string(), })?; + substitute_directive_bindings(&mut directive.for_each, scope, file_label)?; let items = match &directive.for_each { Value::Array(items) => items.clone(), @@ -559,9 +569,23 @@ fn expand_for_each_directive( .entry("item".to_string()) .or_insert_with(|| element.clone()); + let mut item_scope = scope.clone(); + item_scope.extend(bindings); + let mut node = directive.template.clone(); - substitute(&mut node, &bindings, file_label)?; - out.push(node); + substitute_directive_bindings(&mut node, &item_scope, file_label)?; + + let iter_loc = format!("{location}[{idx}]"); + out.extend(resolve_entry( + node, + defs, + file_label, + &iter_loc, + stack, + depth + 1, + budget, + &item_scope, + )?); } Ok(out) } @@ -571,12 +595,16 @@ fn expand_use_directive( defs: &HashMap, file_label: &str, location: &str, -) -> Result<(String, Value)> { - let directive: UseDirective = + scope: &HashMap, +) -> Result<(String, Value, HashMap)> { + let mut directive: UseDirective = serde_json::from_value(entry).map_err(|e| RustmotionError::UseDirectiveInvalid { path: format!("{file_label}: {location}"), reason: e.to_string(), })?; + for prop_value in directive.props.values_mut() { + substitute_directive_bindings(prop_value, scope, file_label)?; + } let def = defs .get(&directive.use_name) @@ -597,14 +625,10 @@ fn expand_use_directive( let mut bindings: HashMap = HashMap::with_capacity(def.params.len()); for (pname, pdef) in &def.params { - match directive.props.get(pname) { - Some(v) => { - bindings.insert(pname.clone(), v.clone()); - } + let (value, origin) = match directive.props.get(pname) { + Some(v) => (v.clone(), "props value"), None => match &pdef.default { - Some(d) => { - bindings.insert(pname.clone(), d.clone()); - } + Some(d) => (d.clone(), "default value"), None => { return Err(RustmotionError::ComponentParamMissing { component: directive.use_name.clone(), @@ -613,12 +637,25 @@ fn expand_use_directive( }) } }, + }; + if !crate::variables::value_matches_declared_type(&value, &pdef.param_type) { + let declared = crate::variables::declared_type_name(&pdef.param_type); + let actual = crate::variables::json_type_name(&value); + return Err(RustmotionError::ComponentParamTypeMismatch { + component: directive.use_name.clone(), + param: pname.clone(), + path: format!("{file_label}: {location}"), + detail: format!( + "is declared as type \"{declared}\" but its {origin} is a {actual}" + ), + }); } + bindings.insert(pname.clone(), value); } let mut node = def.template.clone(); - substitute(&mut node, &bindings, file_label)?; - Ok((directive.use_name.clone(), node)) + substitute_directive_bindings(&mut node, &bindings, file_label)?; + Ok((directive.use_name.clone(), node, bindings)) } fn describe_value(v: &Value) -> String { @@ -962,6 +999,23 @@ mod tests { } } + #[test] + fn use_with_a_prop_value_of_the_wrong_declared_type_is_a_named_error() { + let out = expand(doc_with_stat_card( + json!({ "label": "Revenue", "value": "not-a-number" }), + )); + let err = out.expect_err("a string prop for a declared `number` param must be rejected"); + match &err { + RustmotionError::ComponentParamTypeMismatch { + component, param, .. + } => { + assert_eq!(component, "stat_card"); + assert_eq!(param, "value"); + } + other => panic!("expected ComponentParamTypeMismatch, got {other}"), + } + } + #[test] fn use_with_an_undeclared_prop_key_is_a_named_error() { let out = expand(doc_with_stat_card( @@ -1125,6 +1179,134 @@ mod tests { assert_eq!(inner[1]["content"], json!(2)); } + fn expand_after_variables(mut value: Value) -> Result { + crate::variables::apply_variables(&mut value, None, "test.json")?; + expand_directives(&mut value, "test.json")?; + Ok(value) + } + + #[test] + fn a_config_variable_does_not_shadow_a_for_each_binding_of_the_same_name() { + let doc = json!({ + "config": { + "label": { "type": "string", "default": "CONFIG-VALUE" } + }, + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": [ { "label": "ITEM-A" }, { "label": "ITEM-B" } ], + "template": { "type": "text", "content": "$label" } + }] + }] + }); + let out = expand_after_variables(doc).expect("expands"); + let children = out["scenes"][0]["children"].as_array().unwrap(); + assert_eq!( + children[0]["content"], + json!("ITEM-A"), + "the for-each item's own `label` must win over the scenario `config` variable of the \ + same name, not be silently overwritten by it: {children:#?}" + ); + assert_eq!(children[1]["content"], json!("ITEM-B")); + } + + #[test] + fn a_config_variable_does_not_shadow_a_use_props_binding_of_the_same_name() { + let doc = json!({ + "config": { + "label": { "type": "string", "default": "CONFIG-VALUE" } + }, + "video": { "width": 100, "height": 100 }, + "components": { + "row": { + "params": { "label": { "type": "string" } }, + "template": { "type": "text", "content": "$label" } + } + }, + "scenes": [{ + "duration": 1.0, + "children": [{ "use": "row", "props": { "label": "PROPS-VALUE" } }] + }] + }); + let out = expand_after_variables(doc).expect("expands"); + assert_eq!( + out["scenes"][0]["children"][0]["content"], + json!("PROPS-VALUE"), + "the `use` site's own props must win over the scenario `config` variable of the same \ + name" + ); + } + + #[test] + fn a_nested_for_each_binding_shadows_the_outer_for_each_binding_of_the_same_name() { + let doc = json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": [ { "label": "OUTER", "tags": [ { "label": "inner-1" }, { "label": "inner-2" } ] } ], + "template": { + "type": "card", + "children": [{ + "for-each": "$tags", + "template": { "type": "text", "content": "$label" } + }] + } + }] + }] + }); + let out = expand(doc).unwrap(); + let inner = out["scenes"][0]["children"][0]["children"] + .as_array() + .unwrap(); + assert_eq!(inner.len(), 2); + assert_eq!( + inner[0]["content"], + json!("inner-1"), + "inner for-each item must shadow the outer item's `label`: {inner:#?}" + ); + assert_eq!(inner[1]["content"], json!("inner-2")); + } + + #[test] + fn an_outer_for_each_binding_still_reaches_a_nested_use_props_two_levels_down() { + let doc = json!({ + "video": { "width": 100, "height": 100 }, + "components": { + "tag_pill": { + "params": { + "tag": { "type": "string" }, + "card_color": { "type": "string" } + }, + "template": { "type": "text", "content": "$tag/$card_color" } + } + }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": [ { "color": "red", "tags": ["a", "b"] } ], + "template": { + "type": "card", + "children": [{ + "for-each": "$tags", + "template": { + "use": "tag_pill", + "props": { "tag": "$item", "card_color": "$color" } + } + }] + } + }] + }] + }); + let out = expand(doc).unwrap(); + let inner = out["scenes"][0]["children"][0]["children"] + .as_array() + .unwrap(); + assert_eq!(inner[0]["content"], json!("a/red"), "{inner:#?}"); + assert_eq!(inner[1]["content"], json!("b/red"), "{inner:#?}"); + } + // ---- the tree-identity proof, at the JSON-value level ---- #[test] diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index bf398426..2972ca8d 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; #[serde(deny_unknown_fields)] pub struct Animation { pub property: String, + #[serde(deserialize_with = "deserialize_nonempty_sorted_keyframes")] pub keyframes: Vec, #[serde(default = "default_easing")] pub easing: EasingType, @@ -18,6 +19,27 @@ pub struct Animation { pub spring: Option, } +fn deserialize_nonempty_sorted_keyframes<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let keyframes = Vec::::deserialize(deserializer)?; + if keyframes.is_empty() { + return Err(serde::de::Error::custom( + "`keyframes` must have at least one entry", + )); + } + for pair in keyframes.windows(2) { + if pair[1].time < pair[0].time { + return Err(serde::de::Error::custom(format!( + "keyframes must be sorted by non-decreasing time: {} is followed by {}", + pair[0].time, pair[1].time + ))); + } + } + Ok(keyframes) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Keyframe { @@ -44,7 +66,7 @@ impl KeyframeValue { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema, Default)] #[serde(rename_all = "snake_case")] pub enum EasingType { #[default] @@ -78,6 +100,65 @@ fn default_easing() -> EasingType { EasingType::EaseOut } +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum EasingTypeWire { + Linear, + EaseIn, + EaseOut, + EaseInOut, + EaseInQuad, + EaseOutQuad, + EaseInCubic, + EaseOutCubic, + EaseInExpo, + EaseOutExpo, + EaseInOutQuad, + EaseInOutExpo, + EaseInBack, + EaseOutBack, + EaseOutElastic, + Bounce, + Spring, + CubicBezier { x1: f64, y1: f64, x2: f64, y2: f64 }, +} + +impl<'de> Deserialize<'de> for EasingType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(match EasingTypeWire::deserialize(deserializer)? { + EasingTypeWire::Linear => EasingType::Linear, + EasingTypeWire::EaseIn => EasingType::EaseIn, + EasingTypeWire::EaseOut => EasingType::EaseOut, + EasingTypeWire::EaseInOut => EasingType::EaseInOut, + EasingTypeWire::EaseInQuad => EasingType::EaseInQuad, + EasingTypeWire::EaseOutQuad => EasingType::EaseOutQuad, + EasingTypeWire::EaseInCubic => EasingType::EaseInCubic, + EasingTypeWire::EaseOutCubic => EasingType::EaseOutCubic, + EasingTypeWire::EaseInExpo => EasingType::EaseInExpo, + EasingTypeWire::EaseOutExpo => EasingType::EaseOutExpo, + EasingTypeWire::EaseInOutQuad => EasingType::EaseInOutQuad, + EasingTypeWire::EaseInOutExpo => EasingType::EaseInOutExpo, + EasingTypeWire::EaseInBack => EasingType::EaseInBack, + EasingTypeWire::EaseOutBack => EasingType::EaseOutBack, + EasingTypeWire::EaseOutElastic => EasingType::EaseOutElastic, + EasingTypeWire::Bounce => EasingType::Bounce, + EasingTypeWire::Spring => EasingType::Spring, + EasingTypeWire::CubicBezier { x1, y1, x2, y2 } => { + if !(0.0..=1.0).contains(&x1) || !(0.0..=1.0).contains(&x2) { + return Err(serde::de::Error::custom(format!( + "cubic_bezier x1/x2 must stay within 0.0..=1.0 (got x1={x1}, x2={x2}); \ + y1/y2 may overshoot freely" + ))); + } + EasingType::CubicBezier { x1, y1, x2, y2 } + } + }) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SpringConfig { #[serde(default = "default_damping")] @@ -294,4 +375,84 @@ mod deny_unknown_fields_tests { assert_eq!(k.time, 0.5); assert!(k.easing.is_some()); } + + #[test] + fn an_empty_keyframe_list_is_rejected() { + let json = json!({ "property": "opacity", "keyframes": [] }); + let err = serde_json::from_value::(json) + .expect_err("an empty keyframes list must be rejected, not silently resolve to 0.0"); + assert!(err.to_string().contains("at least one"), "got: {err}"); + } + + #[test] + fn unsorted_keyframe_times_are_rejected() { + let json = json!({ + "property": "opacity", + "keyframes": [ + { "time": 1.0, "value": 1.0 }, + { "time": 0.5, "value": 0.0 } + ] + }); + let err = serde_json::from_value::(json).expect_err( + "keyframes out of time order must be rejected, not silently pin the wrong end", + ); + assert!(err.to_string().contains("sorted"), "got: {err}"); + } + + #[test] + fn a_single_keyframe_is_still_accepted() { + let json = json!({ "property": "opacity", "keyframes": [{ "time": 0.0, "value": 1.0 }] }); + let a: Animation = serde_json::from_value(json).unwrap(); + assert_eq!(a.keyframes.len(), 1); + } + + #[test] + fn equal_consecutive_keyframe_times_are_still_accepted() { + let json = json!({ + "property": "opacity", + "keyframes": [ + { "time": 0.0, "value": 0.0 }, + { "time": 0.0, "value": 1.0 }, + { "time": 1.0, "value": 1.0 } + ] + }); + let a: Animation = serde_json::from_value(json).unwrap(); + assert_eq!(a.keyframes.len(), 3); + } + + #[test] + fn cubic_bezier_x_control_points_outside_0_1_are_rejected() { + let json = json!({ + "property": "opacity", + "keyframes": [{ "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }], + "easing": { "cubic_bezier": { "x1": 3.0, "y1": 0.0, "x2": -2.0, "y2": 1.0 } } + }); + let err = serde_json::from_value::(json).expect_err( + "cubic_bezier x1/x2 outside 0.0..=1.0 must be rejected: Newton's method assumes \ + a monotonic curve there, same as the CSS spec constrains", + ); + assert!( + err.to_string().contains("0.0..=1.0") || err.to_string().contains("x1"), + "got: {err}" + ); + } + + #[test] + fn cubic_bezier_y_control_points_may_overshoot() { + let json = json!({ + "property": "scale", + "keyframes": [{ "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }], + "easing": { "cubic_bezier": { "x1": 0.34, "y1": 1.56, "x2": 0.64, "y2": 1.0 } } + }); + let a: Animation = serde_json::from_value(json).unwrap(); + assert_eq!( + a.easing, + EasingType::CubicBezier { + x1: 0.34, + y1: 1.56, + x2: 0.64, + y2: 1.0 + } + ); + } } diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index c24b363c..3fdbb6e9 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -25,6 +25,7 @@ pub enum ScrollDirection { /// Config for the `gradient_shift` preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct GradientShiftConfig { pub colors: Vec, #[serde(default = "default_bg_type")] @@ -33,6 +34,7 @@ pub struct GradientShiftConfig { /// Config for the `grid_dots` preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct GridDotsConfig { #[serde(default = "default_grid_dots_color")] pub color: String, @@ -53,6 +55,7 @@ fn default_grid_dots_color() -> String { /// code panel. Same scroll machinery (`x`/`y`/`speed`/`direction`) as the /// other tiled presets. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct GridLinesConfig { /// Line colour (hex, alpha welcome). #[serde(default = "default_grid_lines_color")] @@ -91,6 +94,7 @@ fn default_grid_lines_major_weight() -> f32 { /// Config for the `concentric_circles` preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ConcentricCirclesConfig { #[serde(default = "default_concentric_color")] pub color: String, @@ -108,6 +112,7 @@ fn default_concentric_color() -> String { /// Config for the `halo` preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct HaloConfig { pub zones: Vec, } @@ -119,6 +124,7 @@ pub struct HaloConfig { /// below 1 with one colour gives the sparse tile field the reference piece /// uses — squares on a ground, some cells simply absent. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct PixelGridConfig { /// Cell colours. One colour fills every drawn cell; several alternate by /// `(row + col)`, which is what makes a checkerboard rather than a field. @@ -211,6 +217,7 @@ fn default_pixel_seed() -> u32 { /// Config for the `heropattern` preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct HeropatternConfig { /// Name of the heropattern (e.g. "plus", "topography", "jigsaw"). pub pattern: String, @@ -319,6 +326,46 @@ const KNOWN_BACKGROUND_PRESETS: &[&str] = &[ "heropattern", ]; +const COMMON_BACKGROUND_KEYS_EVERY_PRESET_ACCEPTS: &[&str] = + &["preset", "x", "y", "speed", "direction"]; + +fn legacy_flat_form_keys_with_no_new_format_equivalent( + preset_str: &str, +) -> &'static [&'static str] { + match preset_str { + "grid_dots" => &["colors", "element_size", "spacing"], + "concentric_circles" => &["colors", "element_size", "spacing", "count"], + "halo" => &["zones"], + "heropattern" => &["pattern", "color", "opacity", "scale"], + "gradient_shift" => &["colors", "gradient_type"], + _ => &[], + } +} + +fn reject_background_keys_outside_the_accepted_set( + map: &serde_json::Map, + preset_str: &str, + is_new_format: bool, +) -> Result<(), E> { + let mut allowed: Vec<&str> = COMMON_BACKGROUND_KEYS_EVERY_PRESET_ACCEPTS.to_vec(); + if is_new_format { + allowed.push(preset_str); + } else { + allowed.extend_from_slice(legacy_flat_form_keys_with_no_new_format_equivalent( + preset_str, + )); + } + for key in map.keys() { + if !allowed.iter().any(|k| k == key) { + return Err(E::custom(format!( + "unknown animated-background key '{key}' for preset '{preset_str}': expected one of {}", + allowed.join(", ") + ))); + } + } + Ok(()) +} + impl<'de> Deserialize<'de> for AnimatedBackground { fn deserialize>(deserializer: D) -> Result { let map: serde_json::Map = @@ -348,15 +395,29 @@ impl<'de> Deserialize<'de> for AnimatedBackground { // Detect new vs legacy format: new format has a sub-object keyed by preset name let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object()); + reject_background_keys_outside_the_accepted_set::( + &map, + preset_str, + is_new_format, + )?; + 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 speed = map + .get("speed") + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or_else(default_bg_speed); let preset = deserialize_preset_config::(preset_str, sub)?; (preset, speed) } else { // Legacy flat format - let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32; + let legacy_speed = map + .get("speed") + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or_else(default_bg_speed); let preset = match preset_str { "grid_dots" => { let color = map @@ -463,13 +524,13 @@ impl<'de> Deserialize<'de> for AnimatedBackground { })?; BackgroundPreset::GradientShift(cfg) } - // Unreachable: `preset_str` was already checked against - // `KNOWN_BACKGROUND_PRESETS` above. - other => { + "grid_lines" | "pixel_grid" => { return Err(serde::de::Error::custom(format!( - "internal error: unhandled animated-background preset '{other}'" + "animated-background preset '{preset_str}' has no legacy flat form — \ + nest its config: {{\"preset\": \"{preset_str}\", \"{preset_str}\": {{...}}}}" ))) } + other => unreachable!("unhandled known animated-background preset '{other}'"), }; (preset, legacy_speed) }; @@ -590,6 +651,7 @@ impl JsonSchema for AnimatedBackground { /// A single glow zone for the "halo" animated-background preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct HaloZone { /// Zone color (hex string). May itself carry an alpha channel /// (`#rrggbbaa`); see [`HaloZone::opacity`] for how the two combine. @@ -619,6 +681,7 @@ pub struct HaloZone { /// Transition configuration for background interpolation between scenes. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct BackgroundTransition { pub duration: f64, #[serde(default = "default_transition_easing")] @@ -763,7 +826,6 @@ fn default_bg_spacing() -> f32 { 60.0 } -#[allow(dead_code)] fn default_bg_speed() -> f32 { 30.0 } @@ -953,6 +1015,40 @@ mod halo_zone_opacity_tests { } } +#[cfg(test)] +mod animated_background_speed_default_tests { + use super::*; + use serde_json::json; + + #[test] + fn nested_form_with_no_speed_key_defaults_to_thirty_like_the_legacy_form() { + let bg: AnimatedBackground = serde_json::from_value(json!({ + "preset": "grid_dots", + "grid_dots": { "color": "#FFFFFF15" } + })) + .unwrap(); + assert_eq!( + bg.speed, 30.0, + "nested form must default `speed` the same as the legacy flat form" + ); + } + + #[test] + fn nested_form_with_explicit_speed_is_unaffected() { + let bg: AnimatedBackground = serde_json::from_value(json!({ + "preset": "grid_dots", + "grid_dots": { "color": "#FFFFFF15" }, + "speed": 0 + })) + .unwrap(); + assert_eq!( + bg.speed, 0.0, + "an explicit `speed: 0` must still be honoured (static is a valid choice, \ + just not a silent default)" + ); + } +} + /// 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` @@ -1111,3 +1207,104 @@ mod animated_background_silent_sink_tests { assert!(matches!(bg.direction, Some(ScrollDirection::Up))); } } + +#[cfg(test)] +mod animated_background_unknown_key_tests { + use super::*; + use serde_json::json; + + #[test] + fn typo_d_top_level_key_on_new_format_is_a_named_error() { + let err = serde_json::from_value::(json!({ + "preset": "grid_dots", + "grid_dots": { "color": "#fff" }, + "spede": 10 + })) + .expect_err("a typo'd top-level key must not be silently ignored"); + assert!(err.to_string().contains("spede"), "got: {err}"); + } + + #[test] + fn typo_d_top_level_key_on_legacy_format_is_a_named_error() { + let err = serde_json::from_value::(json!({ + "preset": "grid_dots", + "colours": ["#fff"] + })) + .expect_err("a typo'd legacy-form key must not be silently ignored"); + assert!(err.to_string().contains("colours"), "got: {err}"); + } + + #[test] + fn typo_d_key_inside_nested_preset_config_is_a_named_error() { + let err = serde_json::from_value::(json!({ + "preset": "grid_lines", + "grid_lines": { "colr": "#fff" } + })) + .expect_err("a typo'd key inside the nested config must not be silently dropped"); + assert!(err.to_string().contains("colr"), "got: {err}"); + } + + #[test] + fn legacy_key_from_another_preset_is_rejected() { + let err = serde_json::from_value::(json!({ + "preset": "grid_dots", + "colors": ["#fff"], + "count": 5 + })) + .expect_err("a legacy key belonging to a different preset must be rejected"); + assert!(err.to_string().contains("count"), "got: {err}"); + } + + #[test] + fn grid_lines_without_nesting_is_a_named_no_legacy_form_error() { + let err = serde_json::from_value::(json!({ + "preset": "grid_lines", + "speed": 10 + })) + .expect_err("grid_lines has no legacy flat form and must say so, not \"internal error\""); + let msg = err.to_string(); + assert!( + !msg.contains("internal error"), + "must not read as an implementation bug, got: {msg}" + ); + assert!(msg.contains("grid_lines"), "got: {msg}"); + } + + #[test] + fn pixel_grid_without_nesting_is_a_named_no_legacy_form_error() { + let err = serde_json::from_value::(json!({ "preset": "pixel_grid" })) + .expect_err("pixel_grid has no legacy flat form and must say so"); + assert!(err.to_string().contains("pixel_grid"), "got: {}", err); + } + + #[test] + fn well_formed_new_and_legacy_forms_still_parse() { + let new_form: AnimatedBackground = serde_json::from_value(json!({ + "preset": "grid_lines", + "grid_lines": { "cell": 50.0 } + })) + .unwrap(); + assert!(matches!(new_form.preset, BackgroundPreset::GridLines(_))); + + let legacy_form: AnimatedBackground = serde_json::from_value(json!({ + "preset": "concentric_circles", + "colors": ["#fff"], + "count": 5 + })) + .unwrap(); + assert!(matches!( + legacy_form.preset, + BackgroundPreset::ConcentricCircles(_) + )); + } + + #[test] + fn background_transition_typo_d_key_is_a_named_error() { + let err = serde_json::from_value::(json!({ + "duration": 1.0, + "esaing": "ease_in_out" + })) + .expect_err("a typo'd `esaing` must not silently keep the default easing"); + assert!(err.to_string().contains("esaing"), "got: {err}"); + } +} diff --git a/crates/rustmotion-core/src/schema/codeblock_types.rs b/crates/rustmotion-core/src/schema/codeblock_types.rs index be28fc81..2f96cad5 100644 --- a/crates/rustmotion-core/src/schema/codeblock_types.rs +++ b/crates/rustmotion-core/src/schema/codeblock_types.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use super::animation::EasingType; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CodeblockChrome { #[serde(default = "default_true")] pub enabled: bool, @@ -14,6 +15,7 @@ pub struct CodeblockChrome { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CodeblockHighlight { pub lines: Vec, #[serde(default = "default_highlight_color")] @@ -25,6 +27,7 @@ pub struct CodeblockHighlight { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CodeblockReveal { pub mode: RevealMode, #[serde(default)] @@ -43,6 +46,7 @@ pub enum RevealMode { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CodeblockState { pub code: String, pub at: f64, @@ -57,6 +61,7 @@ pub struct CodeblockState { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CodeblockCursor { #[serde(default = "default_true")] pub enabled: bool, @@ -95,3 +100,71 @@ fn default_cursor_color() -> String { fn default_cursor_width() -> f32 { 2.0 } + +#[cfg(test)] +mod unknown_field_tests { + use super::*; + + #[test] + fn chrome_typo_d_key_is_a_named_error() { + let err = serde_json::from_value::(serde_json::json!({ + "titel": "main.rs" + })) + .expect_err("a typo'd `titel` must not silently default `title` to None"); + assert!(err.to_string().contains("titel"), "got: {err}"); + } + + #[test] + fn reveal_typo_d_key_is_a_named_error() { + let err = serde_json::from_value::(serde_json::json!({ + "mode": "typewriter", + "duratoin": 2.0 + })) + .expect_err("a typo'd `duratoin` must not silently keep the default duration"); + assert!(err.to_string().contains("duratoin"), "got: {err}"); + } + + #[test] + fn highlight_typo_d_key_is_a_named_error() { + let err = serde_json::from_value::(serde_json::json!({ + "lines": [1, 2], + "colour": "#ff0000" + })) + .expect_err("a typo'd `colour` must not silently keep the default highlight color"); + assert!(err.to_string().contains("colour"), "got: {err}"); + } + + #[test] + fn state_typo_d_key_is_a_named_error() { + let err = serde_json::from_value::(serde_json::json!({ + "code": "fn main() {}", + "at": 1.0, + "duraton": 0.6 + })) + .expect_err("a typo'd `duraton` must not silently keep the default state duration"); + assert!(err.to_string().contains("duraton"), "got: {err}"); + } + + #[test] + fn cursor_typo_d_key_is_a_named_error() { + let err = serde_json::from_value::(serde_json::json!({ + "colour": "#ffffff" + })) + .expect_err("a typo'd `colour` must not silently keep the default cursor color"); + assert!(err.to_string().contains("colour"), "got: {err}"); + } + + #[test] + fn well_formed_configs_still_parse() { + let chrome: CodeblockChrome = + serde_json::from_value(serde_json::json!({ "title": "main.rs" })).unwrap(); + assert_eq!(chrome.title.as_deref(), Some("main.rs")); + + let reveal: CodeblockReveal = serde_json::from_value(serde_json::json!({ + "mode": "typewriter", + "duration": 2.0 + })) + .unwrap(); + assert_eq!(reveal.duration, 2.0); + } +} diff --git a/crates/rustmotion-core/src/schema/mod.rs b/crates/rustmotion-core/src/schema/mod.rs index 43d1a2fe..e1fe631a 100644 --- a/crates/rustmotion-core/src/schema/mod.rs +++ b/crates/rustmotion-core/src/schema/mod.rs @@ -16,3 +16,60 @@ pub fn generate_json_schema() -> serde_json::Value { let schema = schemars::schema_for!(scenario::Scenario); serde_json::to_value(schema).unwrap() } + +pub(crate) fn fold_separators_and_camel_case_boundaries(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + let mut prev_was_word_char = false; + for c in s.chars() { + if c == '-' || c == '_' || c == ' ' || c == '.' { + if !out.ends_with('_') { + out.push('_'); + } + prev_was_word_char = false; + continue; + } + if c.is_uppercase() && prev_was_word_char && !out.ends_with('_') { + out.push('_'); + } + out.extend(c.to_lowercase()); + prev_was_word_char = true; + } + out +} + +#[cfg(test)] +mod fold_separators_and_camel_case_boundaries_tests { + use super::*; + + #[test] + fn camel_case_gets_a_word_boundary_inserted() { + assert_eq!( + fold_separators_and_camel_case_boundaries("translateX"), + "translate_x" + ); + assert_eq!( + fold_separators_and_camel_case_boundaries("originX"), + "origin_x" + ); + } + + #[test] + fn mixed_dot_and_underscore_conventions_fold_to_the_same_shape() { + assert_eq!( + fold_separators_and_camel_case_boundaries("position.x"), + fold_separators_and_camel_case_boundaries("position_x") + ); + } + + #[test] + fn already_canonical_names_are_unchanged() { + assert_eq!( + fold_separators_and_camel_case_boundaries("translate_x"), + "translate_x" + ); + assert_eq!( + fold_separators_and_camel_case_boundaries("opacity"), + "opacity" + ); + } +} diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 72d56a47..d86f318c 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -440,11 +440,10 @@ 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); + let normalized = super::fold_separators_and_camel_case_boundaries(value); if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES .iter() - .find(|known| normalize(known) == normalized) + .find(|known| super::fold_separators_and_camel_case_boundaries(known) == normalized) { Err(E::custom(format!( "unknown camera keyframe property '{value}' — did you mean '{suggestion}'?" @@ -1093,4 +1092,14 @@ mod camera_keyframe_property_tests { "expected a did-you-mean nudge toward origin.x, got: {msg}" ); } + + #[test] + fn camel_case_origin_property_is_a_named_error_with_a_did_you_mean() { + let json = r#"{ "property": "originX", "values": [ { "time": 0.0, "value": 1.0 } ] }"#; + let err = serde_json::from_str::(json) + .expect_err("originX must be rejected — the real property is origin.x"); + let msg = err.to_string(); + assert!(msg.contains("originX"), "got: {msg}"); + assert!(msg.contains("did you mean 'origin.x'"), "got: {msg}"); + } } diff --git a/crates/rustmotion-core/src/schema/style.rs b/crates/rustmotion-core/src/schema/style.rs index 5735cda4..901fec78 100644 --- a/crates/rustmotion-core/src/schema/style.rs +++ b/crates/rustmotion-core/src/schema/style.rs @@ -50,6 +50,7 @@ pub enum CardJustify { /// A single step in a component's animation timeline. /// Triggers a set of animations at a specific time within the scene. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TimelineStep { /// Time (in seconds, relative to component start) when this step begins. pub at: f64, @@ -63,8 +64,11 @@ pub struct TimelineStep { pub style: Option>, } +const FONT_WEIGHT_MIN: u16 = 100; +const FONT_WEIGHT_MAX: u16 = 900; + /// Font weight — named ("normal"/"bold") or numeric (100-900) -#[derive(Debug, Clone, JsonSchema, Default)] +#[derive(Debug, Clone, Default)] pub enum FontWeight { #[default] Normal, @@ -72,6 +76,49 @@ pub enum FontWeight { Weight(u16), } +impl JsonSchema for FontWeight { + fn schema_name() -> String { + "FontWeight".to_string() + } + + fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + use schemars::schema::*; + + let keyword_schema: Schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + enum_values: Some(vec!["normal".into(), "bold".into()]), + ..Default::default() + } + .into(); + + let numeric_schema: Schema = SchemaObject { + instance_type: Some(InstanceType::Integer.into()), + number: Some(Box::new(NumberValidation { + minimum: Some(FONT_WEIGHT_MIN as f64), + maximum: Some(FONT_WEIGHT_MAX as f64), + ..Default::default() + })), + ..Default::default() + } + .into(); + + SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + one_of: Some(vec![keyword_schema, numeric_schema]), + ..Default::default() + })), + metadata: Some(Box::new(Metadata { + description: Some(format!( + "\"normal\", \"bold\", or an integer {FONT_WEIGHT_MIN}-{FONT_WEIGHT_MAX}" + )), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + #[allow(dead_code)] impl FontWeight { pub fn to_skia_weight(&self) -> i32 { @@ -93,6 +140,16 @@ impl Serialize for FontWeight { } } +fn font_weight_in_range_or_named_error(v: f64) -> Result { + if v.fract() != 0.0 || v < FONT_WEIGHT_MIN as f64 || v > FONT_WEIGHT_MAX as f64 { + return Err(E::custom(format!( + "font weight {v} out of range: expected \"normal\", \"bold\", or an integer \ + {FONT_WEIGHT_MIN}-{FONT_WEIGHT_MAX}" + ))); + } + Ok(FontWeight::Weight(v as u16)) +} + impl<'de> Deserialize<'de> for FontWeight { fn deserialize>(deserializer: D) -> Result { struct FontWeightVisitor; @@ -109,13 +166,13 @@ impl<'de> Deserialize<'de> for FontWeight { } } fn visit_u64(self, v: u64) -> Result { - Ok(FontWeight::Weight(v as u16)) + font_weight_in_range_or_named_error(v as f64) } fn visit_i64(self, v: i64) -> Result { - Ok(FontWeight::Weight(v as u16)) + font_weight_in_range_or_named_error(v as f64) } fn visit_f64(self, v: f64) -> Result { - Ok(FontWeight::Weight(v as u16)) + font_weight_in_range_or_named_error(v) } } deserializer.deserialize_any(FontWeightVisitor) @@ -243,3 +300,89 @@ where deserializer.deserialize_any(OneOrMany) } + +#[cfg(test)] +mod font_weight_tests { + use super::*; + + #[test] + fn out_of_range_integer_is_a_named_error_not_a_silent_wraparound() { + let err = serde_json::from_str::("70000") + .expect_err("70000 must not silently wrap to 4464 via `as u16`"); + let msg = err.to_string(); + assert!( + msg.contains("70000") && msg.contains("100") && msg.contains("900"), + "error must name the offending value and the valid range, got: {msg}" + ); + } + + #[test] + fn fractional_value_is_a_named_error_not_a_silent_floor() { + let err = serde_json::from_str::("0.5") + .expect_err("0.5 must not silently floor to FontWeight::Weight(0)"); + assert!( + err.to_string().contains("0.5"), + "error must name the offending value, got: {err}" + ); + } + + #[test] + fn in_range_integer_and_keywords_still_parse() { + assert!(matches!( + serde_json::from_str::("700").unwrap(), + FontWeight::Weight(700) + )); + assert!(matches!( + serde_json::from_str::(r#""bold""#).unwrap(), + FontWeight::Bold + )); + assert!(matches!( + serde_json::from_str::(r#""normal""#).unwrap(), + FontWeight::Normal + )); + } + + #[test] + fn exported_schema_accepts_the_shapes_the_parser_accepts() { + let mut generator = schemars::gen::SchemaGenerator::default(); + let schema = ::json_schema(&mut generator); + let json = serde_json::to_value(&schema).unwrap(); + let one_of = json["oneOf"] + .as_array() + .expect("FontWeight's schema must be a `oneOf` of [keyword, number]"); + assert_eq!(one_of.len(), 2, "expected exactly 2 branches, got: {json}"); + assert!( + !json.to_string().contains("\"Weight\""), + "the externally-tagged `Weight` shape must not leak into the exported schema, got: {json}" + ); + } +} + +#[cfg(test)] +mod timeline_step_tests { + use super::*; + + #[test] + fn typo_d_key_is_a_named_error_not_a_silently_inert_step() { + let err = serde_json::from_value::(serde_json::json!({ + "at": 0.2, + "styel": { "opacity": 0.0 } + })) + .expect_err("a typo'd `styel` must not parse into an accepted, inert step"); + assert!( + err.to_string().contains("styel"), + "error must name the offending key, got: {err}" + ); + } + + #[test] + fn well_formed_step_still_parses() { + let step: TimelineStep = serde_json::from_value(serde_json::json!({ + "at": 0.2, + "style": { "opacity": 0.0 } + })) + .unwrap(); + assert_eq!(step.at, 0.2); + assert!(step.style.is_some()); + } +} diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 0496632a..88bc65ec 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -551,11 +551,10 @@ 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); + let normalized = crate::schema::fold_separators_and_camel_case_boundaries(value); if let Some(suggestion) = KNOWN_MOTION_PROPERTIES .iter() - .find(|known| normalize(known) == normalized) + .find(|known| crate::schema::fold_separators_and_camel_case_boundaries(known) == normalized) { Err(E::custom(format!( "unknown animation property '{value}' — did you mean '{suggestion}'?" @@ -891,6 +890,7 @@ pub enum Fill { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Gradient { #[serde(rename = "type")] pub gradient_type: GradientType, @@ -909,6 +909,7 @@ pub enum GradientType { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Stroke { pub color: String, #[serde(default = "default_stroke_width")] @@ -928,6 +929,7 @@ pub enum ImageFit { // --- Shape Text --- #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ShapeText { pub content: String, #[serde(default = "default_font_size")] @@ -951,6 +953,7 @@ pub struct ShapeText { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CaptionWord { pub text: String, pub start: f64, @@ -974,6 +977,7 @@ pub enum CaptionStyle { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct GradientBorder { pub colors: Vec, #[serde(default = "default_gradient_border_width")] @@ -988,6 +992,7 @@ fn default_gradient_border_width() -> f32 { /// Inner shadow configuration (inset shadow). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct InnerShadow { pub color: String, #[serde(default)] @@ -1003,6 +1008,7 @@ fn default_inner_shadow_blur() -> f32 { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TextShadow { #[serde(default = "default_shadow_color")] pub color: String, @@ -1015,6 +1021,7 @@ pub struct TextShadow { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TextBackground { pub color: String, #[serde(default = "default_text_bg_padding")] @@ -1322,6 +1329,21 @@ mod motion_property_tests { ); } + #[test] + fn wiggle_camel_case_property_gets_a_did_you_mean() { + let json = json!({ + "name": "wiggle", + "property": "translateX", + "amplitude": 10.0, + "frequency": 1.0 + }); + let err = serde_json::from_value::(json) + .expect_err("camelCase must not silently resolve to a snake_case no-op"); + let msg = err.to_string(); + assert!(msg.contains("translateX"), "got: {msg}"); + assert!(msg.contains("did you mean 'translate_x'"), "got: {msg}"); + } + #[test] fn keyframes_animation_known_property_still_works() { let json = json!({ @@ -1354,7 +1376,9 @@ mod motion_property_tests { 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}"); + let msg = err.to_string(); + assert!(msg.contains("positionX"), "got: {msg}"); + assert!(msg.contains("did you mean 'position.x'"), "got: {msg}"); } #[test] diff --git a/crates/rustmotion-core/src/traits/painter.rs b/crates/rustmotion-core/src/traits/painter.rs index a9fc2ab0..4b644941 100644 --- a/crates/rustmotion-core/src/traits/painter.rs +++ b/crates/rustmotion-core/src/traits/painter.rs @@ -26,7 +26,7 @@ pub struct PaintCtx { pub fps: u32, pub video_width: u32, pub video_height: u32, - /// Stagger offset accumulated by parent containers (seconds). + /// Stagger offset accumulated by parent containers (seconds); already folded into `props`/CSS, so no `Painter` impl should read or subtract it. pub stagger_offset: f64, } @@ -45,10 +45,12 @@ pub struct MeasureCtx { /// Component paint contract. pub trait Painter { - /// Paint the component's *content* into the canvas. The canvas is - /// already translated to the content-box origin and clipped if - /// `overflow: hidden` was set. Generic decorations (bg, border, - /// shadow) are already painted by the engine. + /// Paint the component's *content* into the canvas. `layout` is + /// content-box-relative with a zero origin — a translation the + /// `PaintDispatcher` that calls this method is responsible for, not + /// `paint_tree` itself. The canvas, on the other hand, is already + /// clipped by `paint_tree` if `overflow: hidden` was set. Generic + /// decorations (bg, border, shadow) are already painted by the engine. /// /// `props` carries the per-component animation state at the current /// frame. Outer paint properties (transform / opacity / filter) have diff --git a/crates/rustmotion-core/src/variables.rs b/crates/rustmotion-core/src/variables.rs index 85b0fdcb..28e47c8b 100644 --- a/crates/rustmotion-core/src/variables.rs +++ b/crates/rustmotion-core/src/variables.rs @@ -8,7 +8,7 @@ use crate::schema::{VariableDefinition, VariableType}; /// Whether `value`'s JSON type matches `var_type` — a `number`-typed /// variable's default or override must actually be a JSON number, etc. -fn value_matches_declared_type(value: &Value, var_type: &VariableType) -> bool { +pub(crate) fn value_matches_declared_type(value: &Value, var_type: &VariableType) -> bool { match var_type { VariableType::String => value.is_string(), VariableType::Number => value.is_number(), @@ -18,7 +18,7 @@ fn value_matches_declared_type(value: &Value, var_type: &VariableType) -> bool { } } -fn json_type_name(value: &Value) -> &'static str { +pub(crate) fn json_type_name(value: &Value) -> &'static str { match value { Value::Null => "null", Value::Bool(_) => "boolean", @@ -29,7 +29,7 @@ fn json_type_name(value: &Value) -> &'static str { } } -fn declared_type_name(var_type: &VariableType) -> &'static str { +pub(crate) fn declared_type_name(var_type: &VariableType) -> &'static str { match var_type { VariableType::String => "string", VariableType::Number => "number", @@ -90,20 +90,31 @@ fn merge_variables( Ok(merged) } -/// Recursively substitute variable references in a JSON value tree. -/// -/// `pub(crate)` (not private) so `crate::expand` can reuse the exact same -/// `$name` / `{"$var": "name"}` / interpolation semantics for component-param -/// and `for-each` item/index bindings, rather than re-implementing a second, -/// subtly-different substitution pass. Same reason `"config"` is skipped here -/// (see the loop below): a component-template clone can itself contain a -/// nested `use`'s `props` block — deliberately *not* named `config`, so this -/// skip does not swallow it (see `expand.rs` module doc for why `props` was -/// chosen over `config` for that field). +const UNEXPANDED_DIRECTIVE_KEYS: &[&str] = &["config", "template", "props", "components"]; + +const UNEXPANDED_NESTED_TEMPLATE_KEYS: &[&str] = &["config", "template", "components"]; + pub(crate) fn substitute( value: &mut Value, vars: &HashMap, path: &str, +) -> Result<()> { + substitute_skipping(value, vars, path, UNEXPANDED_DIRECTIVE_KEYS) +} + +pub(crate) fn substitute_directive_bindings( + value: &mut Value, + vars: &HashMap, + path: &str, +) -> Result<()> { + substitute_skipping(value, vars, path, UNEXPANDED_NESTED_TEMPLATE_KEYS) +} + +fn substitute_skipping( + value: &mut Value, + vars: &HashMap, + path: &str, + skip_keys: &[&str], ) -> Result<()> { match value { Value::String(s) => { @@ -138,20 +149,20 @@ pub(crate) fn substitute( } } - // Recurse into object values, but skip "variables" key (don't substitute in definitions) + // Recurse into object values, but skip reserved directive/definition keys let keys: Vec = map.keys().cloned().collect(); for key in keys { - if key == "config" { + if skip_keys.contains(&key.as_str()) { continue; } if let Some(v) = map.get_mut(&key) { - substitute(v, vars, path)?; + substitute_skipping(v, vars, path, skip_keys)?; } } } Value::Array(arr) => { for item in arr.iter_mut() { - substitute(item, vars, path)?; + substitute_skipping(item, vars, path, skip_keys)?; } } _ => {} @@ -728,6 +739,28 @@ mod tests { )); } + #[test] + fn apply_variables_does_not_rewrite_for_each_template_bodies() { + let mut doc = json!({ + "config": { "label": { "type": "string", "default": "CONFIG-VALUE" } }, + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": [ { "label": "ITEM-A" } ], + "template": { "type": "text", "content": "$label" } + }] + }] + }); + apply_defaults(&mut doc).unwrap(); + assert_eq!( + doc["scenes"][0]["children"][0]["template"]["content"], + json!("$label"), + "the for-each template body must still carry the unresolved binding after \ + apply_variables, for expand::expand_directives to fill in per item: {doc:#?}" + ); + } + #[test] fn declared_variable_reference_still_resolves_with_no_override() { let mut doc = json!({ diff --git a/crates/rustmotion-html/src/element.rs b/crates/rustmotion-html/src/element.rs index 0722aa9d..42dba2db 100644 --- a/crates/rustmotion-html/src/element.rs +++ b/crates/rustmotion-html/src/element.rs @@ -2,11 +2,17 @@ use markup5ever_rcdom::{Handle, NodeData}; use serde_json::{Map, Value}; use crate::style::{coerce_value, parse_anim_attr, parse_inline_style}; -use crate::{check_known_attrs, element_attrs, tag_name, HtmlError}; +use crate::{check_known_attrs, element_attrs, suggest, tag_name, HtmlError, MAX_NESTING_DEPTH}; const KNOWN_NATIVE_ATTRS: &[&str] = &["style", "anim"]; -enum TagKind { +const KNOWN_TAG_NAMES: &[&str] = &[ + "div", "section", "main", "header", "footer", "article", "p", "span", "h1", "h2", "h3", "h4", + "h5", "h6", "strong", "em", "label", "b", "i", "code", "u", "small", "a", "br", "img", "video", + "svg", "script", "title", "noscript", "template", "head", +]; + +pub(crate) enum TagKind { Container, Text, /// Tags that never visually render in real HTML either (`

top

"##; + html_to_scenario_value(html) + .expect("