Merge pull request #123 from storytold/fix/93-angle-revolutions

Angles: the revolutions ("0x") scrub and take typing (#93)
This commit is contained in:
BFlat 2026-10-07 01:29:27 +07:00 committed by GitHub
commit 4a401a4785
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 125 additions and 44 deletions

View File

@ -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);
}

View File

@ -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<f64> {
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<f64>) {
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()

View File

@ -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);

View File

@ -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 { "" };

View File

@ -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<i64>, 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::<f64>(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::<f64>(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());

View File

@ -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(&deg).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);
}

View File

@ -91,8 +91,9 @@ one), `panel.tab.Viewer(<n>)` and `viewers.<n>` (View ▸ New Viewer's other Com
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.nestedMarker.<i>` (a precomp's comp markers), `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` (an angle's
revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.revolutions` and
`properties.prop.<uid>.revolutions`) and `effects.item.<name>`. 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.<menu>Item.<n>`),
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,