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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/dev-methodology.local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# dev-methodology preferences (user answers — change only when the user does)
commit: claude-allowed
create-pr: claude-allowed
merge-pr: claude-allowed # granted 2026-07-18 ("tu as l'autorisation de squash and merge toi-même")
feature-merge: squash # feature → main
workstream-merge: squash
tracking: github-issues # parent chantier issue + one issue per workstream
model-strategy: split # orchestration/review: session model (Fable); implementation sub-agents: sonnet
attribution: none # never cite Claude as author/co-author in commits, PRs, or issues
46 changes: 46 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
push:
branches: [main]
pull_request:

env:
CARGO_TERM_COLOR: always

jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --all --check

clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
# webkit/gtk/xdo: required to compile rustmotion-studio (dioxus desktop)
run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libwebkit2gtk-4.1-dev libgtk-3-dev libxdo-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
# webkit/gtk/xdo: required to compile rustmotion-studio (dioxus desktop)
run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libwebkit2gtk-4.1-dev libgtk-3-dev libxdo-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace
5 changes: 5 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Painter/render entry points legitimately thread many context parameters
# (canvas, layout, props, ctx, …); restructuring them into bags would hurt
# call-site clarity more than it helps.
too-many-arguments-threshold = 16
type-complexity-threshold = 400
141 changes: 95 additions & 46 deletions crates/rustmotion-cli/src/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
//! This walker runs the new CSS-engine pipeline (taffy + cosmic-text) so the
//! geometry it checks matches what the renderer will actually paint.

use rustmotion::components::box_builder::build_scene_from_refs;
use rustmotion::components::intrinsic::TextIntrinsic;
use rustmotion::components::{ChildComponent, Component};
use rustmotion::core::css::taffy_bridge::ConversionContext;
use rustmotion::core::engine::box_tree::{AvailableSpace, BoxNode, IntrinsicMeasure};
use rustmotion::core::engine::layout_pass::{run_layout, BoxLayout, LayoutResult};
use rustmotion::engine::animator::{
apply_orbits, apply_wiggles, extract_effects, resolve_animations, AnimatedProperties,
};
use rustmotion::components::box_builder::build_scene_from_refs;
use rustmotion::components::intrinsic::TextIntrinsic;
use rustmotion::engine::render;
use rustmotion::schema::ResolvedScenario;
use serde::Serialize;
Expand Down Expand Up @@ -59,6 +59,7 @@ pub enum Axis {

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[allow(clippy::enum_variant_names)] // "Overflow" postfix is load-bearing: serde output matches CLI docs
pub enum ViolationKind {
/// Component bbox crosses the viewport edge.
ViewportOverflow,
Expand Down Expand Up @@ -271,7 +272,10 @@ fn check_unwrappable_text(
if let Component::Text(t) = component {
let nowrap = matches!(
t.style.white_space,
Some(rustmotion::core::css::style::WhiteSpace::Nowrap | rustmotion::core::css::style::WhiteSpace::Pre)
Some(
rustmotion::core::css::style::WhiteSpace::Nowrap
| rustmotion::core::css::style::WhiteSpace::Pre
)
);
if !nowrap {
return;
Expand Down Expand Up @@ -316,7 +320,11 @@ fn check_auto_scroll(
let font_size = cb.style.font_size_px_or(14.0);
let actual_line_height = cb.style.line_height_for(font_size);
let line_count = cb.code.lines().count().max(1) as f32;
let chrome_h = if cb.chrome.as_ref().is_some_and(|c| c.enabled) { 36.0 } else { 0.0 };
let chrome_h = if cb.chrome.as_ref().is_some_and(|c| c.enabled) {
36.0
} else {
0.0
};
let pad = 32.0; // ~16 top + 16 bottom default
let natural_h = chrome_h + pad + line_count * actual_line_height;
if natural_h > bbox.h + 0.5 {
Expand Down Expand Up @@ -587,15 +595,25 @@ fn resolve_props_for_validation(
return AnimatedProperties::default();
}
}
let base = if let Some(start) = start_at { time - start } else { time };
let base = if let Some(start) = start_at {
time - start
} else {
time
};
base.max(0.0)
} else {
time
};

let mut props = AnimatedProperties::default();
for (preset, preset_config) in &extracted.presets {
let p = resolve_animations(&[], Some(preset), Some(preset_config), anim_time, scene_duration);
let p = resolve_animations(
&[],
Some(preset),
Some(preset_config),
anim_time,
scene_duration,
);
props.merge(&p);
}
if !extracted.keyframes.is_empty() {
Expand Down Expand Up @@ -671,6 +689,37 @@ fn hint_for_animated(
}
}

/// Render a violation for human consumption (multi-line, color-free).
pub fn format_violation(v: &GeometryViolation) -> String {
let axis_str = match v.axis {
Axis::X => "x",
Axis::Y => "y",
Axis::Both => "x+y",
};
let kind_str = match v.kind {
ViolationKind::ViewportOverflow => "viewport overflow",
ViolationKind::UnwrappableTextOverflow => "wrap=false but text too wide",
ViolationKind::AutoScrollDisabledOverflow => "auto_scroll=false but content too tall",
ViolationKind::AnimatedTextOverflow => "animation pushes content outside viewport",
};
format!(
"ERROR: {} ({})\n view: {}, scene: {}\n path: {}\n bbox: [{:.0}, {:.0}] -> [{:.0}, {:.0}] (viewport: {}x{})\n axis: {}\n hint: {}",
v.component,
kind_str,
v.view_index,
v.scene_index,
v.path,
v.bbox.x,
v.bbox.y,
v.bbox.x + v.bbox.w,
v.bbox.y + v.bbox.h,
v.viewport.0,
v.viewport.1,
axis_str,
v.hint,
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -698,7 +747,11 @@ mod tests {
}"##;
let scenario = parse(json);
let violations = validate_geometry(&scenario);
assert!(violations.is_empty(), "expected clean, got: {:?}", violations);
assert!(
violations.is_empty(),
"expected clean, got: {:?}",
violations
);
}

#[test]
Expand All @@ -721,12 +774,27 @@ mod tests {
}"##;
let scenario = parse(json);
let violations = validate_geometry(&scenario);
let viewport = violations.iter().find(|v| v.kind == ViolationKind::ViewportOverflow);
assert!(viewport.is_some(), "missing viewport violation in {:?}", violations);
let viewport = violations
.iter()
.find(|v| v.kind == ViolationKind::ViewportOverflow);
assert!(
viewport.is_some(),
"missing viewport violation in {:?}",
violations
);
let v = viewport.unwrap();
assert_eq!(v.axis, Axis::X, "expected X-axis overflow, got {:?}", v.axis);
assert_eq!(
v.axis,
Axis::X,
"expected X-axis overflow, got {:?}",
v.axis
);
assert_eq!(v.component, "shape");
assert!(v.path.contains("children[0]"), "path missing index: {}", v.path);
assert!(
v.path.contains("children[0]"),
"path missing index: {}",
v.path
);
}

#[test]
Expand All @@ -751,8 +819,14 @@ mod tests {
}"##;
let scenario = parse(json);
let violations = validate_geometry(&scenario);
let v = violations.iter().find(|v| v.kind == ViolationKind::UnwrappableTextOverflow);
assert!(v.is_some(), "missing UnwrappableTextOverflow in {:?}", violations);
let v = violations
.iter()
.find(|v| v.kind == ViolationKind::UnwrappableTextOverflow);
assert!(
v.is_some(),
"missing UnwrappableTextOverflow in {:?}",
violations
);
let v = v.unwrap();
assert_eq!(v.component, "text");
assert_eq!(v.axis, Axis::X);
Expand Down Expand Up @@ -801,39 +875,14 @@ mod tests {
}"##;
let scenario = parse(json);
let violations = validate_geometry(&scenario);
let v = violations.iter().find(|v| v.kind == ViolationKind::AutoScrollDisabledOverflow);
assert!(v.is_some(), "missing AutoScrollDisabledOverflow in {:?}", violations);
let v = violations
.iter()
.find(|v| v.kind == ViolationKind::AutoScrollDisabledOverflow);
assert!(
v.is_some(),
"missing AutoScrollDisabledOverflow in {:?}",
violations
);
assert_eq!(v.unwrap().component, "codeblock");
}
}

/// Render a violation for human consumption (multi-line, color-free).
pub fn format_violation(v: &GeometryViolation) -> String {
let axis_str = match v.axis {
Axis::X => "x",
Axis::Y => "y",
Axis::Both => "x+y",
};
let kind_str = match v.kind {
ViolationKind::ViewportOverflow => "viewport overflow",
ViolationKind::UnwrappableTextOverflow => "wrap=false but text too wide",
ViolationKind::AutoScrollDisabledOverflow => "auto_scroll=false but content too tall",
ViolationKind::AnimatedTextOverflow => "animation pushes content outside viewport",
};
format!(
"ERROR: {} ({})\n view: {}, scene: {}\n path: {}\n bbox: [{:.0}, {:.0}] -> [{:.0}, {:.0}] (viewport: {}x{})\n axis: {}\n hint: {}",
v.component,
kind_str,
v.view_index,
v.scene_index,
v.path,
v.bbox.x,
v.bbox.y,
v.bbox.x + v.bbox.w,
v.bbox.y + v.bbox.h,
v.viewport.0,
v.viewport.1,
axis_str,
v.hint,
)
}
12 changes: 10 additions & 2 deletions crates/rustmotion-cli/src/commands/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> {
let total_layers: usize = all_scenes.iter().map(|s| s.children.len()).sum();

println!("File: {}", input.display());
println!("Resolution: {}x{}", scenario.video.width, scenario.video.height);
println!(
"Resolution: {}x{}",
scenario.video.width, scenario.video.height
);
println!("FPS: {}", fps);
println!("Duration: {:.1}s ({} frames)", total_duration, total_frames);
println!("Views: {}", scenario.views.len());
Expand All @@ -29,7 +32,12 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> {
schema::ViewType::Slide => "Slide",
schema::ViewType::World => "World",
};
println!(" View {}: {} ({} scenes)", vi + 1, vtype, view.scenes.len());
println!(
" View {}: {} ({} scenes)",
vi + 1,
vtype,
view.scenes.len()
);
for (si, scene) in view.scenes.iter().enumerate() {
let scene_frames = (scene.duration * fps as f64).round() as u32;
println!(
Expand Down
Loading
Loading