diff --git a/CLAUDE.md b/CLAUDE.md index 54c15ac0..979931a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,5 +23,6 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e ## Running and looking at the app - `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`). -- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`; or headless: `cargo run -p effectcraft-cli -- snapshot out.png`. +- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen). +- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l `); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen. - Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-`; keep every `Cargo.toml` valid (the `crates/*` glob). diff --git a/Cargo.lock b/Cargo.lock index ae0a711f..e0e23472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,7 @@ dependencies = [ "effectcraft-color", "effectcraft-engine", "effectcraft-geom", + "effectcraft-host", "effectcraft-keyframe", "effectcraft-path", "effectcraft-project", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index f329dfaa..6adad101 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -95,8 +95,17 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc { if let Some(v) = f_p(p, "tracking") { d.tracking = v; } - if let Some(v) = f_p(p, "leading") { - d.leading = Some(v); + match p.get("leading") { + Some(Value::Number(n)) => d.leading = n.as_f64(), + // "auto" or null: Auto Leading (120% of the font size). + Some(Value::String(_) | Value::Null) => d.leading = None, + _ => {} + } + if let Some(v) = b_p(p, "applyFill") { + d.apply_fill = v; + } + if let Some(v) = b_p(p, "applyStroke") { + d.apply_stroke = v; } if let Some(v) = b_p(p, "allCaps") { d.all_caps = v; @@ -108,10 +117,14 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc { d.faux_italic = v; } if let Some(j) = str_p(p, "justify") { + // AE's seven Paragraph alignment buttons. d.justify = match j.to_ascii_lowercase().as_str() { "center" | "centre" => Justify::Center, "right" => Justify::Right, - "justify" | "justifyall" => Justify::JustifyAll, + "justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft, + "justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter, + "justifyright" | "justifylastright" => Justify::JustifyLastRight, + "justifyall" => Justify::JustifyAll, _ => Justify::Left, }; } @@ -927,7 +940,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, text?, size?, font?, style?, fill?, stroke?, strokeWidth?, tracking?, leading?, justify?, allCaps?, fauxBold?, fauxItalic?}", + "{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, fauxBold?, fauxItalic?}", has_layers, set_text ), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 0b4e20ff..df2b7af9 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -56,13 +56,23 @@ fn go(s: &mut Session, p: &Value) -> Result { } else { comp.layers.iter().filter(|l| s.state.selected_layers.contains(&l.id)).collect() }; + // `prop` (uid) narrows the search to one property: the Properties panel's ◀ ▶ arrows. + let only = p.get("prop").and_then(Value::as_u64); + if only.is_some() { + times.clear(); + } for l in layers { l.props.walk("", &mut |_, pr| { + if only.is_some_and(|u| u != pr.uid) { + return; + } for k in &pr.keys { times.push(l.comp_time(k.time)); } }); - times.extend(l.markers.iter().map(|m| l.comp_time(m.time))); + if only.is_none() { + times.extend(l.markers.iter().map(|m| l.comp_time(m.time))); + } } times.sort(); times.dedup(); @@ -93,6 +103,6 @@ pub fn specs() -> Vec { cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))), cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))), cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))), - cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey}", has_comp, go), + cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go), ] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52..bea8c2d0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -114,3 +114,58 @@ fn time_navigation() { s.execute("time.set", json!({"frame": 45})).unwrap(); assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50); } + +#[test] +fn time_navigation_per_property() { + let mut s = demo(); + // An animated property and the comp times of its keys. + let comp = s.active_comp().unwrap().clone(); + let mut found = None; + for l in &comp.layers { + l.props.walk("", &mut |_, pr| { + if found.is_none() && pr.keys.len() >= 2 { + found = Some((pr.uid, pr.keys.iter().map(|k| l.comp_time(k.time)).collect::>())); + } + }); + } + let (uid, times) = found.expect("demo has an animated property"); + s.set_time(effectcraft_time::Tick::ZERO); + let half = comp.frame_duration().0 / 2; + let expect = times.iter().copied().filter(|t| t.0 > half).min().unwrap(); + s.execute("time.go", json!({"to": "nextKey", "prop": uid})).unwrap(); + let snap = |t| comp.frame_rate.snap(t); + assert_eq!(s.time(), snap(expect)); + // Going back from past the last key lands on the last key. + s.set_time(comp.duration); + s.execute("time.go", json!({"to": "prevKey", "prop": uid})).unwrap(); + assert_eq!(s.time(), snap(*times.iter().max().unwrap())); +} + +#[test] +fn set_text_paragraph_fill_and_leading() { + use effectcraft_keyframe::{Justify, Value as KValue}; + let mut s = demo(); + let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + let doc = |s: &Session| { + let l = s.active_comp().unwrap().layer(crate::project::LayerId(t)).unwrap().clone(); + match &l.props.prop("text/sourceText").unwrap().value { + KValue::Text(d) => (**d).clone(), + v => panic!("{v:?}"), + } + }; + for (key, j) in [ + ("justifyLeft", Justify::JustifyLastLeft), + ("justifyCenter", Justify::JustifyLastCenter), + ("justifyRight", Justify::JustifyLastRight), + ("justifyAll", Justify::JustifyAll), + ("center", Justify::Center), + ] { + s.execute("layer.setText", json!({"layer": t, "justify": key})).unwrap(); + assert_eq!(doc(&s).justify, j, "{key}"); + } + s.execute("layer.setText", json!({"layer": t, "leading": 50, "applyFill": false, "applyStroke": true})).unwrap(); + let d = doc(&s); + assert_eq!((d.leading, d.apply_fill, d.apply_stroke), (Some(50.0), false, true)); + s.execute("layer.setText", json!({"layer": t, "leading": "auto"})).unwrap(); + assert_eq!(doc(&s).leading, None); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 512b9b14..d27ef79c 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -31,3 +31,4 @@ kurbo = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } image = { workspace = true } pollster = "0.4" +effectcraft-host = { workspace = true } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs new file mode 100644 index 00000000..6cc9b175 --- /dev/null +++ b/crates/ui-egui/examples/snapshot.rs @@ -0,0 +1,130 @@ +//! Headless UI snapshot: runs the real [`EffectcraftApp`] without a window (egui_kittest + wgpu) +//! and writes PNGs. Works regardless of window focus, Spaces or display sleep, so agents can +//! look at the UI at any time. +//! +//! ```text +//! cargo run -p effectcraft-ui-egui --example snapshot -- [--out ui.png] [--size 1680x1020] +//! [--scale 2] [--settle 1.5] [--empty] [--script steps.jsonl | --step '']... +//! ``` +//! +//! Each script step is a control-channel request (`{"method":"engine.execute","params":{...}}`, +//! see `docs/control-protocol.md`), run in order with the app settling in between. The extra +//! method `{"method":"snap","params":{"path":"x.png"}}` writes an intermediate snapshot. +//! Replies are printed to stdout as JSON lines. + +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use effectcraft_ui_egui::{ControlRequest, EffectcraftApp}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +struct Args { + out: String, + size: (f32, f32), + scale: f32, + settle: f64, + demo: bool, + steps: Vec, +} + +fn parse_args() -> Result { + let mut a = Args { out: "ui.png".into(), size: (1680.0, 1020.0), scale: 2.0, settle: 1.5, demo: true, steps: Vec::new() }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + let mut val = || it.next().ok_or_else(|| format!("{arg} needs a value")); + match arg.as_str() { + "--out" => a.out = val()?, + "--size" => { + let v = val()?; + let (w, h) = v.split_once('x').ok_or("--size WxH")?; + a.size = (w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?); + } + "--scale" => a.scale = val()?.parse().map_err(|_| "bad --scale")?, + "--settle" => a.settle = val()?.parse().map_err(|_| "bad --settle")?, + "--empty" => a.demo = false, + "--step" => a.steps.push(serde_json::from_str(&val()?).map_err(|e| format!("--step: {e}"))?), + "--script" => { + let path = val()?; + let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) { + a.steps.push(serde_json::from_str(line).map_err(|e| format!("{path}: {e}: {line}"))?); + } + } + "-h" | "--help" => { + return Err("usage: snapshot [--out ui.png] [--size WxH] [--scale 2] [--settle secs] [--empty] [--script f.jsonl | --step json]...".into()); + } + _ => return Err(format!("unknown argument {arg}")), + } + } + Ok(a) +} + +/// Step the app for `secs` of wall time so background frame renders land in the viewer. +fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) { + let end = Instant::now() + Duration::from_secs_f64(secs); + while Instant::now() < end { + h.step(); + std::thread::sleep(Duration::from_millis(30)); + } + h.step(); +} + +fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> { + let img = h.render()?; + img.save(path).map_err(|e| format!("{path}: {e}"))?; + println!("{}", json!({"snapshot": path, "width": img.width(), "height": img.height()})); + Ok(()) +} + +fn main() { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + }; + let mut session = effectcraft_host::session(); + if args.demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let (tx, rx) = mpsc::channel::(); + let mut harness = Harness::builder() + .with_size(egui::vec2(args.size.0, args.size.1)) + .with_pixels_per_point(args.scale) + .wgpu() + .build_eframe(move |_cc| EffectcraftApp::new(session).with_control(rx)); + settle(&mut harness, args.settle); + for step in &args.steps { + let method = step["method"].as_str().unwrap_or_default().to_string(); + let params = step.get("params").cloned().unwrap_or(json!({})); + if method == "snap" { + if let Err(e) = snap(&mut harness, params["path"].as_str().unwrap_or("snap.png")) { + eprintln!("{e}"); + } + continue; + } + let (reply_tx, reply_rx) = mpsc::channel(); + let _ = tx.send(ControlRequest { method: method.clone(), params, reply: reply_tx }); + // Requests are answered on a later frame (some wait for input to be processed). + let deadline = Instant::now() + Duration::from_secs(10); + let reply = loop { + harness.step(); + if let Ok(v) = reply_rx.try_recv() { + break v; + } + if Instant::now() > deadline { + break json!({"ok": false, "error": "no reply within 10 s"}); + } + std::thread::sleep(Duration::from_millis(10)); + }; + println!("{}", json!({"method": method, "reply": reply})); + settle(&mut harness, 0.3); + } + settle(&mut harness, args.settle.min(1.0)); + if let Err(e) = snap(&mut harness, &args.out) { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7a523c89..8a8840cb 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -240,19 +240,12 @@ pub fn workspace(name: &str) -> DockNode { ), tabs(&[Timeline, RenderQueue], 0), ), - // Default: Project/Effect Controls | Composition | Info, Audio, Preview, Effects & Presets… - _ => vsplit( - Ratio(0.56), - hsplit( - FixedA(300.0), - tabs(&[Project, EffectControls], 0), - hsplit( - FixedB(290.0), - tabs(&[Composition, Layer], 0), - vsplit(Ratio(0.42), tabs(&[Info, Audio, Preview], 2), tabs(&[EffectsPresets, Properties, Character, Paragraph, Align], 0)), - ), - ), - tabs(&[Timeline, RenderQueue], 0), + // Default (as in After Effects): Project/Effect Controls | Composition over the Timeline, + // and a full-height right column with Preview above the Properties panel. + _ => hsplit( + FixedB(300.0), + vsplit(Ratio(0.56), hsplit(FixedA(300.0), tabs(&[Project, EffectControls], 0), tabs(&[Composition, Layer], 0)), tabs(&[Timeline, RenderQueue], 0)), + vsplit(FixedA(250.0), tabs(&[Preview, Info, Audio], 0), tabs(&[Properties, EffectsPresets, Character, Paragraph, Align], 0)), ), } } @@ -411,7 +404,15 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa } /// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus). -pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry) -> Vec { +/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style). +pub fn draw_group_chrome( + ui: &mut egui::Ui, + g: &Group, + focused: PanelKind, + t: &Tokens, + reg: &mut crate::automation::Registry, + title: &dyn Fn(PanelKind) -> String, +) -> Vec { let mut actions = Vec::new(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); @@ -429,7 +430,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T let text_y = strip.min.y + 16.0; for (i, p) in g.panels.iter().enumerate() { let is_active = i == g.active; - let galley = painter.layout_no_wrap(p.title().to_string(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); + let label = title(*p); + let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); let menu_w = if is_active { 20.0 } else { 0.0 }; let w = galley.size().x + 16.0 + menu_w; if x + w > strip.max.x - 20.0 && i > g.active { @@ -445,7 +447,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T } let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()); - reg.add(&format!("panel.tab.{}", p.id()), tab, p.title()); + reg.add(&format!("panel.tab.{}", p.id()), tab, &label); let label_x = tab.min.x + 8.0; let label_w = galley.size().x; let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b7f089f..821a946d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -467,14 +467,31 @@ impl EffectcraftApp { } } + /// Tab labels that name what the panel shows, as After Effects does: "Composition Intro", + /// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp. + fn tab_titles(&self) -> Vec<(PanelKind, String)> { + let mut out = Vec::new(); + let Some(comp) = self.session.active_comp() else { return out }; + let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); + out.push((PanelKind::Composition, format!("Composition {cname}"))); + out.push((PanelKind::Timeline, cname)); + if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { + out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); + out.push((PanelKind::Properties, format!("Properties: {}", l.name))); + } + out + } + fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { let t = self.tokens; let mut dock = std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }); let mut groups = Vec::new(); dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); let mut actions = Vec::new(); + let titles = self.tab_titles(); + let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto)); + actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); } self.ui.dock = dock; for g in &groups { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bb418ccd..0397c552 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1,5 +1,4 @@ -//! Effect Controls (the selected layer's effects with all parameters) and the Properties panel -//! (essential properties: transform + text/shape basics). +//! Effect Controls: the selected layer's effects with all parameters. use effectcraft_engine::keyframe::Value; use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property}; @@ -376,41 +375,3 @@ impl EffectKind for PropGroup { } } } - -/// Properties panel: the selected layer's transform (and text basics), always visible. -pub fn properties(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); - let ctx = ui.ctx().clone(); - let Some(layer) = selected_layer(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer", Tokens::ui(12.0), t.text_faint); - return; - }; - let Some(cid) = app.session.active_comp_id() else { return }; - let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default())); - let snap_project = app.session.project.clone(); - let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() }; - let mut y = rect.min.y + 8.0; - p.text(pos2(rect.min.x + 10.0, y + 8.0), Align2::LEFT_CENTER, &layer.name, Tokens::semibold(13.0), t.text); - p.text(pos2(rect.min.x + 10.0, y + 24.0), Align2::LEFT_CENTER, layer.source.type_name(), Tokens::ui(11.0), t.text_dim); - y += 40.0; - let mut actions: Actions = vec![]; - if let Some(tr) = layer.transform().cloned() { - p.text(pos2(rect.min.x + 10.0, y + 10.0), Align2::LEFT_CENTER, "Layer Transform", Tokens::semibold(12.0), t.text); - y += 24.0; - for pr in tr.props() { - if pr.three_d_only && !layer.is_3d() { - continue; - } - let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 24.0)); - prop_row(app, ui, &p, &layer, pr, &ectx, r, 16.0, &mut actions); - y += 24.0; - } - } - for (id, params) in actions { - if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { - app.ui.status = e; - } - } -} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9d4e3848..2d5924dd 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ pub mod effects_presets; pub mod info; pub mod misc; pub mod project; +pub mod properties; pub mod text_panels; pub mod timeline; pub mod viewer; @@ -58,7 +59,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Character => text_panels::character(app, ui, rect), PanelKind::Paragraph => text_panels::paragraph(app, ui, rect), PanelKind::Align => text_panels::align(app, ui, rect), - PanelKind::Properties => effect_controls::properties(app, ui, rect), + PanelKind::Properties => properties::show(app, ui, rect), PanelKind::Audio => misc::audio(app, ui, rect), PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs new file mode 100644 index 00000000..7e41c82d --- /dev/null +++ b/crates/ui-egui/src/panels/properties.rs @@ -0,0 +1,508 @@ +//! Properties panel: the selected layer's essential properties in one context-sensitive stack, +//! like After Effects' Properties panel. Sections appear by layer type: +//! +//! - **Layer Transform** (all layers but cameras/lights): keyframe navigator (◀ ◆ ▶) or +//! stopwatch, scrubbable values, linked Scale, `Nx+N°` Rotation, Reset. +//! - **Text** (text layers): font family/style, size, leading, tracking, stroke width, fill and +//! stroke with enable checkboxes; "More" opens the Character panel. +//! - **Paragraph** (text layers): the seven alignment buttons; "More" opens the Paragraph panel. +//! - **Text Animation** (text layers): Add Animator menu. + +use effectcraft_engine::keyframe::{Justify, TextDoc, Value}; +use effectcraft_engine::project::{Layer, LayerSource, Property}; +use effectcraft_engine::render::EvalCtx; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use crate::dock::PanelKind; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +type Actions = Vec<(String, serde_json::Value)>; + +const ROW_H: f32 = 22.0; +const PAD: f32 = 12.0; + +/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property). +const ANIMATORS: &[(&str, &str)] = &[ + ("Anchor Point", "anchor"), + ("Position", "position"), + ("Scale", "scale"), + ("Skew", "skew"), + ("Rotation", "rotation"), + ("Opacity", "opacity"), + ("Fill Color", "fillColor"), + ("Stroke Color", "strokeColor"), + ("Stroke Width", "strokeWidth"), + ("Tracking", "tracking"), + ("Line Spacing", "lineSpacing"), + ("Character Offset", "characterOffset"), + ("Blur", "blur"), +]; + +/// AE's seven Paragraph alignment buttons: (justify, `layer.setText` key). +const ALIGNS: [(Justify, &str); 7] = [ + (Justify::Left, "left"), + (Justify::Center, "center"), + (Justify::Right, "right"), + (Justify::JustifyLastLeft, "justifyLeft"), + (Justify::JustifyLastCenter, "justifyCenter"), + (Justify::JustifyLastRight, "justifyRight"), + (Justify::JustifyAll, "justifyAll"), +]; + +/// Value text the way AE prints it: whole numbers without decimals, otherwise one decimal. +fn decimals(v: f64) -> usize { + if (v - v.round()).abs() < 1e-6 { 0 } else { 1 } +} + +/// Rotation as AE shows it: revolutions and remaining degrees (`1x+30°`, `-0x-45°`). +pub fn split_rotation(deg: f64) -> (i64, f64) { + let rev = (deg / 360.0).trunc(); + (rev as i64, deg - rev * 360.0) +} + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + let ctx = ui.ctx().clone(); + let comp = app.session.active_comp().cloned(); + let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|id| c.layer(*id)).cloned()); + let (Some(comp), Some(cid), Some(layer)) = (comp, app.session.active_comp_id(), layer) else { + p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer to see its properties", Tokens::ui(12.0), t.text_faint); + return; + }; + let project = app.session.project.clone(); + let expr = app.session.expr.clone(); + let now = app.session.time(); + let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() }; + let x0 = rect.min.x + PAD; + let w = rect.width() - 2.0 * PAD; + // Vertical scrolling: content height is known after drawing, so clamp with last frame's. + let scroll_id = egui::Id::new("props-scroll"); + let (mut scroll, content_h): (f32, f32) = ui.data(|d| d.get_temp(scroll_id)).unwrap_or((0.0, 0.0)); + if ui.rect_contains_pointer(rect) { + scroll -= ui.input(|i| i.smooth_scroll_delta.y); + } + scroll = scroll.clamp(0.0, (content_h - rect.height()).max(0.0)); + let top = rect.min.y + 8.0 - scroll; + let mut y = top; + let mut actions: Actions = vec![]; + + // ---------------------------------------------------------------- Layer Transform + if let Some(tr) = layer.transform().cloned() { + section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); + let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H)); + let resp = ui.interact(rr, egui::Id::new("props-reset"), Sense::click()); + p.text(rr.right_center(), Align2::RIGHT_CENTER, "Reset", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent }); + app.auto.add("properties.transform.reset", rr, "Reset Transform"); + if resp.clicked() { + for pr in tr.props() { + actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid}))); + } + } + y += ROW_H + 2.0; + for pr in tr.props() { + if pr.three_d_only && !layer.is_3d() { + continue; + } + transform_row(app, ui, &p, &layer, pr, &ectx, Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &mut actions); + y += ROW_H; + } + y += 10.0; + } + + // ---------------------------------------------------------------- Text / Paragraph / Animation + if matches!(layer.source, LayerSource::Text) + && let Some(doc) = effectcraft_engine::render::text::source_text(&ectx, &layer) + { + divider(&p, x0, w, y - 4.0, &t); + y = text_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions); + divider(&p, x0, w, y - 4.0, &t); + y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions); + divider(&p, x0, w, y - 4.0, &t); + section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation"); + y += ROW_H + 10.0; + let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0)); + let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click()); + p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); + icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text); + p.text(pos2(br.min.x + 26.0, br.center().y), Align2::LEFT_CENTER, "Add Animator", Tokens::ui(12.0), t.text); + app.auto.add("properties.addAnimator", br, "Add Animator"); + let pop = egui::Id::new("props-add-animator-pop"); + if resp.clicked() { + widgets::open_popup(ui, pop); + } + let labels: Vec = ANIMATORS.iter().map(|(l, _)| l.to_string()).collect(); + if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) { + actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": ANIMATORS[i].1}))); + } + y += 24.0; + } + + y += 40.0; + let content_h = y - top; + ui.data_mut(|d| d.insert_temp(scroll_id, (scroll, content_h))); + if content_h > rect.height() { + // Thin scroll indicator. + let frac = rect.height() / content_h; + let bar_h = (rect.height() * frac).max(24.0); + let by = rect.min.y + (rect.height() - bar_h) * (scroll / (content_h - rect.height()).max(1.0)); + p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6)); + } + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; + } + } +} + +fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) { + let t = app.tokens; + p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text); + let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)); + let _ = ui; + app.auto.add(auto, r, title); +} + +fn divider(p: &egui::Painter, x0: f32, w: f32, y: f32, t: &Tokens) { + p.line_segment([pos2(x0 - 4.0, y), pos2(x0 + w + 4.0, y)], Stroke::new(1.0, t.separator)); +} + +fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { + let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); +} + +/// One transform row: keyframe navigator or stopwatch, name, value fields. +#[allow(clippy::too_many_arguments)] +fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { + let t = app.tokens; + let cy = r.center().y; + let uid = pr.uid; + let lid = layer.id.0; + let base = format!("properties.prop.{uid}"); + if pr.is_animated() { + // ◀ ◆ ▶: previous key, add/remove key at the CTI, next key. + let now = ectx.time; + let half = ectx.comp.frame_duration().0 / 2; + let on_key = pr.keys.iter().any(|k| (layer.comp_time(k.time).0 - now.0).abs() <= half); + let cells = [("prev", r.min.x + 4.0), ("key", r.min.x + 16.0), ("next", r.min.x + 28.0)]; + for (what, cx) in cells { + let cr = Rect::from_center_size(pos2(cx, cy), vec2(12.0, 16.0)); + let resp = ui.interact(cr, egui::Id::new(("props-nav", uid, what)), Sense::click()); + let col = if resp.hovered() { t.text } else { t.text_dim }; + match what { + "prev" => triangle(p, cr.center(), true, col), + "next" => triangle(p, cr.center(), false, col), + _ => { + let kr = Rect::from_center_size(cr.center(), vec2(10.0, 10.0)); + if on_key { + icons::paint(p, kr, Icon::Keyframe, t.keyframe); + } else { + let c = kr.center(); + let d = 4.5; + p.add(egui::Shape::closed_line( + vec![pos2(c.x, c.y - d), pos2(c.x + d, c.y), pos2(c.x, c.y + d), pos2(c.x - d, c.y)], + Stroke::new(1.0, col), + )); + } + } + } + app.auto.add(&format!("{base}.{what}Key"), cr, &pr.name); + if resp.clicked() { + match what { + "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), + "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))), + } + } + } + } else { + let swr = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0)); + let resp = ui.interact(swr, egui::Id::new(("props-sw", uid)), Sense::click()); + icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); + if resp.clicked() { + actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid}))); + } + } + p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); + let vx = r.min.x + (r.width() * 0.5).max(130.0); + let merge = format!("props-{uid}"); + let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge}))); + let value = ectx.value(layer, pr); + let is_scale = pr.name == "Scale"; + let pct = is_scale || pr.name == "Opacity"; + let suffix = if pct { "%" } else { "" }; + match &value { + Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { + let (rev, deg) = split_rotation(*v); + let g = p.layout_no_wrap(format!("{rev}x{}", if deg < 0.0 { "" } else { "+" }), Tokens::ui(12.0), t.hot_text); + let gw = g.size().x; + p.galley(pos2(vx + 2.0, cy - g.size().y / 2.0), g, t.hot_text); + let (vr, nv, _) = + widgets::hot_number_at(ui, pos2(vx + gw, cy - 9.0), egui::Id::new(("props-v", uid)), deg, 0.5, (-1e9, 1e9), decimals(deg), "°", &t); + app.auto.add(&format!("{base}.value"), vr, &pr.name); + if let Some(nv) = nv { + set(actions, json!(rev as f64 * 360.0 + nv)); + } + } + Value::Scalar(v) => { + let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) }; + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t); + app.auto.add(&format!("{base}.value"), vr, &pr.name); + if let Some(nv) = nv { + set(actions, json!(nv)); + } + } + Value::Vec2(_) | Value::Vec3(_) => { + let c = value.components(); + let n = if pr.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { pr.shown_dims as usize } else { c.len() }; + let mut x = vx; + let link_id = egui::Id::new(("props-scale-link", lid)); + let linked = is_scale && ui.data(|d| d.get_temp::(link_id)).unwrap_or(true); + if is_scale { + // Constrain Proportions toggle, left of the values. + let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0)); + let resp = ui.interact(lr, link_id.with("btn"), Sense::click()); + icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); + app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions"); + if resp.clicked() { + ui.data_mut(|d| d.insert_temp(link_id, !linked)); + } + } + for d in 0..n.min(c.len()) { + let (vr, nv, _) = + widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t); + app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); + if let Some(nv) = nv { + let mut nc = c.clone(); + if linked && c[d].abs() > 1e-9 { + let k = nv / c[d]; + for v in nc.iter_mut().take(n) { + *v *= k; + } + } + nc[d] = nv; + set(actions, json!(nc)); + } + x = vr.max.x + 10.0; + } + } + _ => {} + } +} + +/// Text section; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn text_section( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + doc: &TextDoc, + x0: f32, + w: f32, + mut y: f32, + actions: &mut Actions, +) -> f32 { + let t = app.tokens; + let lid = layer.id.0; + let set = |actions: &mut Actions, v: serde_json::Value| { + let mut v = v; + v["layer"] = json!(lid); + actions.push(("layer.setText".into(), v)); + }; + section_header(app, ui, p, x0, w, y, "Text", "properties.text"); + y += ROW_H + 4.0; + // Font family and style. + let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let pop = egui::Id::new("props-font-pop"); + if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add("properties.text.font", fr, "Font family"); + let fams: Vec = effectcraft_engine::text_families(); + if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) { + set(actions, json!({"font": fams[i]})); + } + y += 28.0; + let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let spop = egui::Id::new("props-style-pop"); + if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("props-style")).clicked() { + widgets::open_popup(ui, spop); + } + app.auto.add("properties.text.style", sr, "Font style"); + let styles: Vec = ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect(); + if let Some(i) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)) { + set(actions, json!({"style": styles[i]})); + } + y += 32.0; + // Size / leading, tracking / stroke width. + let col2 = x0 + w / 2.0; + let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| { + p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::new(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t); + app.auto.add(&format!("properties.text.{key}"), r, key); + nv + }; + if let Some(v) = field(app, ui, x0, y, "T", "size", doc.size, (1.0, 2000.0), " px") { + set(actions, json!({"size": v, "merge": "props-text-size"})); + } + match doc.leading { + Some(l) => { + if let Some(v) = field(app, ui, col2, y, "A", "leading", l, (0.0, 5000.0), " px") { + set(actions, json!({"leading": v, "merge": "props-text-leading"})); + } + } + None => { + // Auto Leading: click to switch to an explicit value (120% of the size). + p.text(pos2(col2, y + 9.0), Align2::LEFT_CENTER, "A", Tokens::semibold(11.0), t.text_dim); + let r = Rect::from_min_size(pos2(col2 + 26.0, y), vec2(40.0, 18.0)); + let resp = ui.interact(r, egui::Id::new("props-leading-auto"), Sense::click()); + p.text(pos2(r.min.x + 2.0, r.center().y), Align2::LEFT_CENTER, "Auto", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.hot_text }); + app.auto.add("properties.text.leading", r, "leading"); + if resp.clicked() { + set(actions, json!({"leading": (doc.size * 1.2).round()})); + } + } + } + y += 26.0; + if let Some(v) = field(app, ui, x0, y, "VA", "tracking", doc.tracking, (-1000.0, 10000.0), "") { + set(actions, json!({"tracking": v.round(), "merge": "props-text-tracking"})); + } + if let Some(v) = field(app, ui, col2, y, "W", "strokeWidth", doc.stroke_width, (0.0, 500.0), " px") { + set(actions, json!({"strokeWidth": v, "merge": "props-text-strokeWidth"})); + } + y += 28.0; + // Fill and Stroke, each with an enable checkbox, swatch and label. + for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { + let ry = y + i as f32 * 24.0; + let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0)); + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() { + let k = if key == "fill" { "applyFill" } else { "applyStroke" }; + set(actions, json!({k: !on})); + } + app.auto.add(&format!("properties.text.{key}.enabled"), cr, label); + let sw = Rect::from_min_size(pos2(x0 + 24.0, ry + 1.0), vec2(16.0, 16.0)); + let pop = egui::Id::new(("props-color-pop", key)); + if widgets::swatch(ui, sw, c, egui::Id::new(("props-color", key)), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("properties.text.{key}"), sw, label); + let mut rgb = [c[0], c[1], c[2]]; + if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")})); + } + p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); + } + y += 52.0; + y = more_button(app, ui, p, x0, y, "props-text-more", "properties.text.more", PanelKind::Character); + y + 10.0 +} + +/// Paragraph section; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn paragraph_section( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + doc: &TextDoc, + x0: f32, + w: f32, + mut y: f32, + actions: &mut Actions, +) -> f32 { + let t = app.tokens; + section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph"); + y += ROW_H + 4.0; + for (i, (j, key)) in ALIGNS.into_iter().enumerate() { + let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0)); + let on = doc.justify == j; + let resp = ui.interact(r, egui::Id::new(("props-para", key)), Sense::click()); + p.rect_filled( + r, + 3.0, + if on { + t.pressed + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }, + ); + paint_align_glyph(p, r, j, if on { t.text } else { t.text_dim }); + app.auto.add(&format!("properties.paragraph.{key}"), r, key); + if resp.clicked() { + actions.push(("layer.setText".into(), json!({"layer": layer.id.0, "justify": key}))); + } + } + y += 30.0; + y = more_button(app, ui, p, x0, y, "props-para-more", "properties.paragraph.more", PanelKind::Paragraph); + y + 10.0 +} + +/// Four lines showing an alignment: last line short for the "justify last …" modes. +fn paint_align_glyph(p: &egui::Painter, r: Rect, j: Justify, c: Color32) { + let full = r.width() - 8.0; + for k in 0..4 { + let ly = r.min.y + 6.0 + k as f32 * 3.5; + let last = k == 3; + let lw = match j { + Justify::Left | Justify::Center | Justify::Right => { + if k % 2 == 1 { + full * 0.6 + } else { + full + } + } + Justify::JustifyAll => full, + _ if last => full * 0.55, + _ => full, + }; + let lx = match j { + Justify::Center | Justify::JustifyLastCenter => r.center().x - lw / 2.0, + Justify::Right | Justify::JustifyLastRight => r.max.x - 4.0 - lw, + _ => r.min.x + 4.0, + }; + p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], Stroke::new(1.2, c)); + } +} + +/// "··· More" button that opens the full panel; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, id: &str, auto: &str, panel: PanelKind) -> f32 { + let t = app.tokens; + let r = Rect::from_min_size(pos2(x0, y), vec2(64.0, 22.0)); + let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + p.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.field_bg }); + p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, "···", Tokens::semibold(12.0), t.text); + p.text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "More", Tokens::ui(12.0), t.text); + app.auto.add(auto, r, "More"); + if resp.clicked() { + app.show_panel(panel); + } + y + 28.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotation_splits_like_ae() { + assert_eq!(split_rotation(0.0), (0, 0.0)); + assert_eq!(split_rotation(390.0), (1, 30.0)); + assert_eq!(split_rotation(-45.0), (0, -45.0)); + assert_eq!(split_rotation(-725.0), (-2, -5.0)); + } + + #[test] + fn decimals_hide_whole_numbers() { + assert_eq!(decimals(960.0), 0); + assert_eq!(decimals(960.5), 1); + } +}