mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Angles: the revolutions ("0x") scrub and take typing (#93)
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.<uid>.revolutions, effectControls.prop.<uid>.revolutions, properties.prop.<uid>.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 <[email protected]>
This commit is contained in:
1 parent
1a9288f83c
commit
27b0eec216
7 files changed
+125
-44
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { "" };
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>`,
|
||||
|
||||
Reference in new issue
Block a user