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.`,