From 27b0eec216e04589820c5f5d9e40b7ddd1479c20 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 01:29:03 +0700 Subject: [PATCH] Angles: the revolutions ("0x") scrub and take typing (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An angle's revolutions were painted text: only the degrees could be dragged or typed, so full turns needed the dial or typing past 360. fx_widgets::angle_field draws `0x+45.0°` as two hot numbers: the revolutions (widgets::hot_int_at, ten pixels a turn, fractions adding up between frames so slow drags still step; a typed value is truncated) and the degrees, each keeping the other. Effect Controls, the Timeline and the Properties panel use it, so they read and edit angles alike (the Timeline showed `-1x-40.0°` and Properties had its own split). Automation ids: timeline.prop..revolutions, effectControls.prop..revolutions, properties.prop..revolutions. Regression test: angle_revolutions_scrub_and_take_typing drags and types the revolutions in all three panels. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 15 ++-- crates/ui-egui/src/panels/fx_widgets.rs | 18 +++++ crates/ui-egui/src/panels/properties.rs | 29 ++------ crates/ui-egui/src/panels/timeline.rs | 12 ++-- crates/ui-egui/src/widgets.rs | 17 +++++ crates/ui-egui/tests/ui_effect_controls.rs | 73 ++++++++++++++++++++ docs/control-protocol.md | 5 +- 7 files changed, 125 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0432d6c3..c579e8e9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -221,18 +221,11 @@ fn prop_row( } } Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => { - let (rev, deg) = fw::split_angle(*v); - let prefix = format!("{rev}x{}", if deg < 0.0 { "-" } else { "+" }); - let g = p.layout_no_wrap(prefix, 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(("ec-v", uid)), deg.abs(), 0.5, (-1e9, 1e9), 1, "°", &t); - let full = Rect::from_min_max(pos2(vx, vr.min.y), vr.max); - app.auto.add(&format!("effectControls.prop.{uid}.value"), full, &fw::format_angle(*v, 1)); + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); + app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { - // The field edits the degrees part; the revolutions stay. - let signed = if deg < 0.0 || (deg == 0.0 && *v < 0.0) { -nv } else { nv }; - set(actions, json!(rev as f64 * 360.0 + signed)); + set(actions, json!(nv)); } twirl_open(app, ui); } diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index 27e671a3..a8fb2074 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -9,6 +9,7 @@ use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use crate::theme::Tokens; +use crate::widgets; // --------------------------------------------------------------------------------------------- // Angles @@ -40,6 +41,23 @@ pub fn parse_angle(s: &str) -> Option { s.parse().ok() } +/// After Effects' angle value, `0x+45.0°`, at `at` (top left): the revolutions and the degrees +/// are hot numbers of their own (drag to scrub, click to type) and changing one keeps the other. +/// Returns the revolutions' rect, the degrees' rect and the new angle when either changed. +pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: usize, t: &Tokens) -> (Rect, Rect, Option) { + let (rev, deg) = split_angle(v); + let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t); + let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text); + let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t); + let out = match (new_rev, new_deg) { + (Some(r), _) => Some(r as f64 * 360.0 + deg), + // The degrees field shows the remainder's size; its sign stays. + (None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }), + (None, None) => None, + }; + (rr, dr, out) +} + /// Dial angle (degrees, 0 = up, clockwise) of `p` around `c` in screen space. pub fn dial_angle(c: Pos2, p: Pos2) -> f64 { ((p.x - c.x) as f64).atan2(-(p.y - c.y) as f64).to_degrees() diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b93dc86f..9cc65461 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2,7 +2,7 @@ //! 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. +//! stopwatch, scrubbable values, linked Scale, `Nx+N°` Rotation (revolutions and degrees scrub on their own), 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. @@ -50,12 +50,6 @@ 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); @@ -259,15 +253,12 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, 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); + let (_, deg) = super::fx_widgets::split_angle(*v); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &t); + app.auto.add(&format!("{base}.value"), dr, &pr.name); + app.auto.add(&format!("{base}.revolutions"), rr, &pr.name); if let Some(nv) = nv { - set(actions, json!(rev as f64 * 360.0 + nv)); + set(actions, json!(nv)); } } Value::Scalar(v) => { @@ -515,14 +506,6 @@ fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x 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); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 0f7b49b1..21e66dd1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2701,19 +2701,15 @@ fn value_editor( Value::Scalar(v) => { let (min, max, dec, speed) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, ((max - min) / 400.0).clamp(0.01, 10.0)), - ParamUi::Angle => (-1e9, 1e9, 1, 0.5), ParamUi::Percent => (-1e6, 1e6, 1, 0.5), _ => (-1e9, 1e9, 1, 1.0), }; if matches!(prop.ui, ParamUi::Angle) { - let rev = (v / 360.0).trunc(); - let deg = v - rev * 360.0; - p.text(pos2(x, at.y), Align2::LEFT_CENTER, format!("{}x", rev as i64), Tokens::ui(12.0), t.hot_text); - x += 22.0; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), deg, speed, (-1e9, 1e9), 1, "°", &t); - app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, 1, &t); + app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name); + app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { - set(actions, json!(rev * 360.0 + nv)); + set(actions, json!(nv)); } } else { let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0c6586c5..25b7ae2b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -74,6 +74,23 @@ pub fn hot_number_at( (rect, out, resp.drag_stopped()) } +/// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every +/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames), +/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished). +pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, speed: f64, suffix: &str, t: &Tokens) -> (Rect, Option, bool) { + let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, speed, (-1e6, 1e6), 0, suffix, t); + let acc_id = id.with("acc"); + let acc = ui.data(|d| d.get_temp::(acc_id)).unwrap_or(0.0) + nv.map_or(0.0, |n| n - value as f64); + // (Ten 0.1 steps add up to just under 1.) + let step = (acc + acc.signum() * 1e-9).trunc(); + if ui.ctx().is_being_dragged(id) && !done { + ui.data_mut(|d| d.insert_temp(acc_id, acc - step)); + } else { + ui.data_mut(|d| d.remove::(acc_id)); + } + (rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done) +} + /// A colour swatch; returns the response (click opens the picker in the caller). pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> Response { let resp = ui.interact(rect, id, Sense::click()); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 42f81c67..63fc4954 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -196,3 +196,76 @@ fn keyer_eyedropper_picks_the_screen_from_the_effect_input() { let img = s.render(s.active_comp_id().unwrap(), s.time(), Default::default()); assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90)); } + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).unwrap_or_else(|| panic!("no {id}")).clone(); + egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +/// Drag one pixel per frame, as a slow hand does. +fn slow_drag(h: &mut Harness<'_, EffectcraftApp>, from: egui::Pos2, dx: f32) { + h.event(egui::Event::PointerMoved(from)); + h.step(); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=dx as i32 { + h.event(egui::Event::PointerMoved(from + egui::vec2(k as f32, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// Click a hot number and type a value over it. +fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) { + let p = rect_of(h, id).center(); + click(h, p); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +/// An angle's revolutions ("0x") scrub and take typing on their own, in Effect Controls and +/// the Timeline, keeping the degrees (#93). +#[test] +fn angle_revolutions_scrub_and_take_typing() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let angle = |h: &Harness<'_, EffectcraftApp>| layer(&h.state().session, x.small).effects().unwrap().find(x.angle).unwrap().value.as_f64(); + h.state_mut().session.execute("prop.set", json!({"layer": x.small, "prop": x.angle, "value": 45.0})).unwrap(); + h.run_steps(3); + let revs = format!("effectControls.prop.{}.revolutions", x.angle); + // Ten pixels a turn, however slowly the pointer moves; the degrees stay. + let from = rect_of(&h, &revs).center(); + slow_drag(&mut h, from, 40.0); + let v = angle(&h); + assert!((v - 45.0).rem_euclid(360.0).abs() < 1e-9 && (2.0..=4.0).contains(&((v - 45.0) / 360.0)), "{v}"); + type_into(&mut h, &revs, "3"); + assert_eq!(angle(&h), 3.0 * 360.0 + 45.0); + let deg = format!("effectControls.prop.{}.value", x.angle); + assert_eq!(h.state().auto.find(°).map(|e| e.label.clone()), Some("3x+45.0°".into())); + // Negative angles keep their sign: -1x-30° → 2x-30°. + h.state_mut().session.execute("prop.set", json!({"layer": x.small, "prop": x.angle, "value": -390.0})).unwrap(); + h.run_steps(3); + type_into(&mut h, &revs, "2"); + assert_eq!(angle(&h), 2.0 * 360.0 - 30.0); + + // The Timeline's Rotation. + let rot = layer(&h.state().session, x.small).props.prop("transform/rotation").unwrap().uid; + h.state_mut().show_panel(PanelKind::Timeline); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.rotation", json!({})).unwrap(); + h.run_steps(4); + type_into(&mut h, &format!("timeline.prop.{rot}.revolutions"), "-2"); + let rotation = |h: &Harness<'_, EffectcraftApp>| layer(&h.state().session, x.small).props.prop("transform/rotation").unwrap().value.as_f64(); + assert_eq!(rotation(&h), -720.0); + // The Properties panel's Rotation. + h.state_mut().show_panel(PanelKind::Properties); + h.run_steps(4); + type_into(&mut h, &format!("properties.prop.{rot}.revolutions"), "1"); + assert_eq!(rotation(&h), 360.0); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bba88083..e00d1fc0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -91,8 +91,9 @@ one), `panel.tab.Viewer()` and `viewers.` (View ▸ New Viewer's other Com renames it), `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..nestedMarker.` (a precomp's comp markers), `timeline.layer..twirl`, `timeline.layer..row` (click selects, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, -`timeline.key..`, `timeline.cti`, `effectControls.prop..value` and -`effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, +`timeline.key..`, `timeline.cti`, `effectControls.prop..value` (an angle's +revolutions are `timeline.prop..revolutions`, `effectControls.prop..revolutions` and +`properties.prop..revolutions`) and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, `viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`, `viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.Item.`), `viewer.ruler.top|left|origin`, `viewer.mask..vertex.`, `viewer.shapePath..vertex.`,