Merge pull request #123 from storytold/fix/93-angle-revolutions
Angles: the revolutions ("0x") scrub and take typing (#93)
This commit is contained in:
commit
4a401a4785
|
|
@ -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>`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue