Merge pull request #436 from storytold/fix/393-370-timeline-ux
Timeline and viewer: resize cursors, time display toggle, pick whip, expression field, Tab, property menu (#393, #370, #362, #363, #371, #361, #407)
This commit is contained in:
commit
4fad3e7af4
|
|
@ -1141,6 +1141,22 @@ pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3])
|
|||
}
|
||||
}
|
||||
|
||||
/// A Transform property's default (Reset) by match id: the anchor at the centre of the source
|
||||
/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation.
|
||||
pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option<KV> {
|
||||
Some(match m {
|
||||
"anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]),
|
||||
"position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]),
|
||||
"positionX" => KV::Scalar(cw / 2.0),
|
||||
"positionY" => KV::Scalar(ch / 2.0),
|
||||
"positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0),
|
||||
"scale" => KV::Vec3([100.0; 3]),
|
||||
"orientation" => KV::Vec3([0.0; 3]),
|
||||
"opacity" => KV::Scalar(100.0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, "layer.transform")?;
|
||||
|
|
@ -1163,14 +1179,12 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
match op.as_str() {
|
||||
"reset" => {
|
||||
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0]));
|
||||
for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] {
|
||||
if let Some(v) = transform_default(m, (w, h), (cw, ch)) {
|
||||
set(tr, m, v);
|
||||
}
|
||||
}
|
||||
update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]);
|
||||
set(tr, "scale", KV::Vec3([100.0; 3]));
|
||||
set(tr, "rotation", KV::Scalar(0.0));
|
||||
set(tr, "rotationX", KV::Scalar(0.0));
|
||||
set(tr, "rotationY", KV::Scalar(0.0));
|
||||
set(tr, "orientation", KV::Vec3([0.0; 3]));
|
||||
set(tr, "opacity", KV::Scalar(100.0));
|
||||
}
|
||||
// Centred in the frame; a 3D layer keeps its depth.
|
||||
"center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]),
|
||||
|
|
|
|||
|
|
@ -164,6 +164,12 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
|
||||
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
|
||||
let (dm, dt) = (dims(from, me), dims(target, them));
|
||||
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
|
||||
let (r, dt) = match t.get("dimension").and_then(Value::as_u64) {
|
||||
Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1),
|
||||
Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))),
|
||||
None => (r, dt),
|
||||
};
|
||||
// While the expression is being edited the reference goes in at the cursor, replacing the
|
||||
// selected text (After Effects): `expression` is the text being edited, `range` the
|
||||
// selection [start, end] in characters.
|
||||
|
|
@ -200,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Pick Whip (Link Property)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)",
|
||||
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
|
||||
has_layers,
|
||||
pick_whip
|
||||
),
|
||||
|
|
|
|||
|
|
@ -271,13 +271,40 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(r)
|
||||
}
|
||||
|
||||
/// A property's default value, when it has a known one: an effect parameter's, or a Transform
|
||||
/// property's (not a camera's or light's).
|
||||
fn default_value(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<effectcraft_keyframe::Value> {
|
||||
let comp = s.project.comp(cid)?;
|
||||
let layer = comp.layer(lid)?;
|
||||
let pr = layer.props.find(uid)?;
|
||||
let effect = layer.props.node_chain(uid)?.into_iter().find_map(|n| match n {
|
||||
effectcraft_project::Node::Group(g) => match &g.kind {
|
||||
GroupKind::Effect { effect } => Some(effect.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
if let Some(effect) = effect {
|
||||
let ps = effectcraft_effects::find(&effect)?.params.iter().find(|ps| ps.id == pr.match_id)?;
|
||||
return Some(effectcraft_effects::default_value(ps, effectcraft_render::effect_bounds(&s.project, comp, layer).0));
|
||||
}
|
||||
if matches!(layer.source, effectcraft_project::LayerSource::Camera | effectcraft_project::LayerSource::Light { .. })
|
||||
|| !layer.transform()?.props().any(|t| t.uid == uid)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
super::layer::transform_default(&pr.match_id, (w as f64, h as f64), (comp.width as f64, comp.height as f64))
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?;
|
||||
let def = p.get("default").cloned();
|
||||
let own = default_value(s, cid, lid, uid);
|
||||
with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| {
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) {
|
||||
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)).or(own) {
|
||||
pr.value = d;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
|
|
@ -783,7 +810,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
toggle_transform_key
|
||||
),
|
||||
cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr),
|
||||
cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset),
|
||||
cmd!(
|
||||
"prop.reset",
|
||||
"Reset Property",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, default?} → removes keyframes and expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's)",
|
||||
has_layers,
|
||||
reset
|
||||
),
|
||||
cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
|
||||
cmd!(
|
||||
"prop.convertExpressionToKeyframes",
|
||||
|
|
|
|||
|
|
@ -482,6 +482,60 @@ fn pick_whip_generates_ae_reference_expressions() {
|
|||
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
|
||||
}
|
||||
|
||||
/// #407: Reset without a value gives a property its own default (a Transform property's, an
|
||||
/// effect parameter's) besides removing its keyframes and expression.
|
||||
#[test]
|
||||
fn reset_gives_a_property_its_default() {
|
||||
let (mut s, l) = setup();
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap();
|
||||
let blur = prop(&s, l, "effects/#1/blurriness").value;
|
||||
let set = [
|
||||
("transform/anchor", json!([10, 20, 0])),
|
||||
("transform/position", json!([10, 20, 0])),
|
||||
("transform/scale", json!([50, 70, 100])),
|
||||
("transform/rotation", json!(30)),
|
||||
("transform/opacity", json!(40)),
|
||||
("effects/#1/blurriness", json!(25)),
|
||||
];
|
||||
for (path, v) in &set {
|
||||
s.execute("prop.set", json!({"layer": l, "path": path, "value": v})).unwrap();
|
||||
}
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(10)), (1.0, json!(20))]);
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
for (path, _) in &set {
|
||||
s.execute("prop.reset", json!({"layer": l, "path": path})).unwrap();
|
||||
}
|
||||
let v = |s: &Session, path: &str| prop(s, l, path).value;
|
||||
assert_eq!(v(&s, "transform/anchor"), KV::Vec3([50.0, 50.0, 0.0]), "the solid's centre");
|
||||
assert_eq!(v(&s, "transform/position"), KV::Vec3([200.0, 150.0, 0.0]), "the comp's centre");
|
||||
assert_eq!(v(&s, "transform/scale"), KV::Vec3([100.0; 3]));
|
||||
assert_eq!(v(&s, "transform/rotation"), KV::Scalar(0.0));
|
||||
assert!(!prop(&s, l, "transform/rotation").is_animated());
|
||||
assert_eq!(v(&s, "transform/opacity"), KV::Scalar(100.0));
|
||||
assert!(prop(&s, l, "transform/opacity").expr.is_none());
|
||||
assert_eq!(v(&s, "effects/#1/blurriness"), blur);
|
||||
// A given default still wins.
|
||||
s.execute("prop.reset", json!({"layer": l, "path": "transform/opacity", "default": 30})).unwrap();
|
||||
assert_eq!(v(&s, "transform/opacity"), KV::Scalar(30.0));
|
||||
}
|
||||
|
||||
/// #363: dropped on one of the target's values the pick whip picks that dimension
|
||||
/// (`position[0]`), as in After Effects; a multi-dimension property follows it on every axis.
|
||||
#[test]
|
||||
fn pick_whip_picks_one_dimension_of_the_target() {
|
||||
let (mut s, l) = setup();
|
||||
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let whip = |s: &mut Session, path: &str, d: u64| {
|
||||
s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": {"layer": o, "path": "transform/position", "dimension": d}}))
|
||||
};
|
||||
let r = "thisComp.layer(\"Other\").transform.position";
|
||||
assert_eq!(whip(&mut s, "transform/rotation", 0).unwrap(), json!(format!("{r}[0]")));
|
||||
assert_eq!(whip(&mut s, "transform/scale", 1).unwrap(), json!(format!("temp = {r}[1];\n[temp, temp]")));
|
||||
assert_eq!(prop(&s, l, "transform/scale").expr.unwrap().text, format!("temp = {r}[1];\n[temp, temp]"));
|
||||
// A 2D layer's Position has two.
|
||||
assert!(whip(&mut s, "transform/rotation", 2).is_err());
|
||||
}
|
||||
|
||||
/// #284: with the expression being edited, the pick whip puts the reference at the cursor,
|
||||
/// replacing the selected characters (After Effects), in one undo step.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -159,7 +159,9 @@ impl EffectcraftApp {
|
|||
panels::precomp::navigator(self, &mut child, nav);
|
||||
content.min.y = nav.max.y;
|
||||
}
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
|
||||
let mut child = ui.new_child(
|
||||
egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())).ui_stack_info(egui::UiStackInfo::default().with_tag(crate::widgets::PANEL_TAG)),
|
||||
);
|
||||
child.set_clip_rect(content.intersect(ui.clip_rect()));
|
||||
panels::show(self, &mut child, p, content);
|
||||
if viewer && !self.ui.start_screen {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,17 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec<String>]) -> Vec<Field> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Fields to type a numeric property value in (`value`, or `value[0]`… per dimension).
|
||||
fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option<Vec<Field>> {
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
Some(match v {
|
||||
KV::Scalar(v) => vec![Field::num("value", name, *v)],
|
||||
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
|
||||
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
|
||||
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
let s = &app.session;
|
||||
|
|
@ -251,13 +262,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64);
|
||||
let Some((pr, t)) = pr.zip(t) else { return false };
|
||||
let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false };
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
let fields = match &k.value {
|
||||
KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)],
|
||||
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
|
||||
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ => return false,
|
||||
};
|
||||
let Some(fields) = value_fields(&pr.name, &k.value) else { return false };
|
||||
(pr.name.clone(), fields)
|
||||
}
|
||||
// A property's context menu ▸ Edit Value…: its value at the current time.
|
||||
"prop.set" if !has(p, &["value"]) => {
|
||||
let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l)));
|
||||
let Some((l, pr)) = l.and_then(|l| Some((l, l.props.find(p.get("prop").and_then(Value::as_u64)?)?))) else { return false };
|
||||
let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false };
|
||||
(pr.name.clone(), fields)
|
||||
}
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
|
|
|
|||
|
|
@ -495,12 +495,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
app.ui.timeline.graph_transform_box = !tbox;
|
||||
}
|
||||
x += 6.0;
|
||||
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| {
|
||||
let layer = comp.layer(*l)?;
|
||||
let tr = layer.transform()?;
|
||||
(tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u)))
|
||||
.then_some(l.0)
|
||||
});
|
||||
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
|
||||
if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
|
||||
&& let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -84,6 +84,18 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
|
|||
effectcraft_engine::commands::time::display_time(session, comp, t)
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between
|
||||
/// Timecode and Frames, as in After Effects (through Project Settings: one undo step).
|
||||
pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) {
|
||||
if !(resp.clicked() && resp.ctx.input(|i| i.modifiers.command)) {
|
||||
return;
|
||||
}
|
||||
let timecode = app.session.project.settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode;
|
||||
if let Err(e) = app.session.execute("file.projectSettings", serde_json::json!({"timeDisplay": if timecode { "frames" } else { "timecode" }})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spacebar held outside a text field: the Hand tool (drags pan the viewer and scroll the
|
||||
/// Timeline).
|
||||
pub(crate) fn space_hand(ctx: &egui::Context) -> bool {
|
||||
|
|
|
|||
|
|
@ -93,14 +93,26 @@ fn stripes(p: &egui::Painter, r: Rect, label: Color32) {
|
|||
/// Row height (expression editors grow with their text).
|
||||
fn row_height(row: &Row, rh: f32) -> f32 {
|
||||
match row.kind {
|
||||
RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0,
|
||||
RowKind::Expr { lines, .. } => rh * lines as f32 + 6.0,
|
||||
RowKind::Waveform { .. } => rh * 3.0,
|
||||
_ => rh,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert an expression editor row after every property that has an expression.
|
||||
fn with_expr_rows(rows: Vec<Row>, comp: &Comp, closed: &std::collections::BTreeSet<u64>) -> Vec<Row> {
|
||||
/// An inline expression editor grows with its text up to this many lines (After Effects)...
|
||||
const EXPR_AUTO_LINES: usize = 8;
|
||||
/// ...and its bottom edge drags it up to this many.
|
||||
const EXPR_MAX_LINES: usize = 100;
|
||||
|
||||
/// The text of the expression being edited (while its editor has the keyboard).
|
||||
fn expr_buf_id(prop: u64) -> egui::Id {
|
||||
egui::Id::new(("expr-buf", prop))
|
||||
}
|
||||
|
||||
/// Insert an expression editor row after every property that has an expression: as many lines
|
||||
/// as its text (the text being edited, so it grows while typing), up to 8, or the height its
|
||||
/// bottom edge was dragged to.
|
||||
fn with_expr_rows(rows: Vec<Row>, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec<Row> {
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
// Rows come grouped by layer: look each layer up once (a linear search per property row
|
||||
// made twirling open many layers of a big comp quadratic).
|
||||
|
|
@ -108,15 +120,17 @@ fn with_expr_rows(rows: Vec<Row>, comp: &Comp, closed: &std::collections::BTreeS
|
|||
for r in rows {
|
||||
let mut extra = None;
|
||||
if let RowKind::Prop { uid } = r.kind
|
||||
&& !closed.contains(&uid)
|
||||
&& !tl.expr_closed.contains(&uid)
|
||||
{
|
||||
if cur.is_none_or(|l| l.id != r.layer) {
|
||||
cur = comp.layer(r.layer);
|
||||
}
|
||||
extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row {
|
||||
layer: r.layer,
|
||||
depth: r.depth,
|
||||
kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) },
|
||||
extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| {
|
||||
let lines = tl.expr_lines.get(&uid).copied().unwrap_or_else(|| {
|
||||
let edited = ctx.data(|d| d.get_temp::<String>(expr_buf_id(uid)));
|
||||
edited.as_deref().unwrap_or(&e.text).split('\n').count().min(EXPR_AUTO_LINES)
|
||||
});
|
||||
Row { layer: r.layer, depth: r.depth, kind: RowKind::Expr { uid, lines: lines.clamp(1, EXPR_MAX_LINES) } }
|
||||
});
|
||||
}
|
||||
out.push(r);
|
||||
|
|
@ -132,6 +146,20 @@ fn pick_whip_id() -> egui::Id {
|
|||
egui::Id::new("tl-pickwhip")
|
||||
}
|
||||
|
||||
/// Autoscroll speed (points per second, up negative) of a drag held at height `y` within
|
||||
/// `zone` of the top or bottom edge of `rows`, or past it: faster the deeper.
|
||||
fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 {
|
||||
let zone = zone.min(rows.height() / 2.0).max(1.0);
|
||||
let depth = if y < rows.min.y + zone {
|
||||
y - (rows.min.y + zone)
|
||||
} else if y > rows.max.y - zone {
|
||||
y - (rows.max.y - zone)
|
||||
} else {
|
||||
return 0.0;
|
||||
};
|
||||
(depth / zone).clamp(-3.0, 3.0) * 400.0
|
||||
}
|
||||
|
||||
/// The text of an expression being edited when its pick whip was pressed, and the selection in
|
||||
/// it (characters): the pick whip's reference goes in there.
|
||||
type WhipInto = (String, [usize; 2]);
|
||||
|
|
@ -1156,9 +1184,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
|
||||
let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag());
|
||||
p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
|
||||
let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64());
|
||||
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint);
|
||||
// Under it the other style (frames under timecode, timecode under frames) and the rate.
|
||||
let settings = &app.session.project.settings;
|
||||
let other = if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode {
|
||||
format!("{:05}", fr.frame_at(time) + settings.frame_start)
|
||||
} else {
|
||||
effectcraft_engine::time::format_timecode_ae(fr.frame_at(time + comp.display_start), fr, false)
|
||||
};
|
||||
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint);
|
||||
app.auto.add("timeline.timecode", tc_rect, &tc);
|
||||
super::toggle_time_display(app, &tc_resp);
|
||||
if tc_resp.dragged() {
|
||||
let d = tc_resp.drag_delta().x as f64;
|
||||
let f = fr.frame_at(time) + (d * 0.5).round() as i64;
|
||||
|
|
@ -1521,7 +1556,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let _ = cw.end;
|
||||
|
||||
// ---- rows.
|
||||
let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed);
|
||||
let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx);
|
||||
let rh = t.row_h;
|
||||
let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
|
||||
let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
|
||||
|
|
@ -1545,7 +1580,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
}
|
||||
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll);
|
||||
// A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in
|
||||
// After Effects.
|
||||
if ctx.data(|d| d.get_temp::<PickWhip>(pick_whip_id())).is_some()
|
||||
&& let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()).filter(|p| rect.x_range().contains(p.x))
|
||||
{
|
||||
let speed = edge_scroll_speed(rows_rect, ptr.y, rh);
|
||||
if speed != 0.0 {
|
||||
app.ui.timeline.scroll_y += speed * ctx.input(|i| i.stable_dt).min(0.1);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll);
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
|
||||
|
|
@ -1567,6 +1613,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag());
|
||||
app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
|
||||
let mut hit_rows: Vec<(Rect, Row)> = vec![];
|
||||
// The value fields of multi-dimension properties (per dimension), for the pick whip.
|
||||
let mut dim_hits: Vec<(u64, Vec<Rect>)> = vec![];
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
// Outline widgets never spill into the time graph (narrow timelines).
|
||||
ui.set_clip_rect(left_clip);
|
||||
|
|
@ -2074,6 +2122,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
RowKind::Expr { uid, lines } => {
|
||||
let Some(prop) = layer.props.find(*uid) else { continue };
|
||||
let Some(ex) = prop.expr.clone() else { continue };
|
||||
let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid)));
|
||||
lp.rect_filled(left, 0.0, t.row_alt);
|
||||
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
|
||||
lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
|
||||
|
|
@ -2104,9 +2153,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// Pressed while the expression is being edited (its text is buffered while
|
||||
// the editor has the focus): the reference will go in at the cursor,
|
||||
// replacing the selection (#284).
|
||||
let into = ctx.data(|d| d.get_temp::<String>(egui::Id::new(("expr-buf", uid)))).map(|buf| {
|
||||
let into = ctx.data(|d| d.get_temp::<String>(buf_id)).map(|buf| {
|
||||
let n = buf.chars().count();
|
||||
let sel = egui::text_edit::TextEditState::load(&ctx, egui::Id::new(("expr-edit", uid)))
|
||||
let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
|
||||
.and_then(|st| st.cursor.char_range())
|
||||
.map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
|
||||
(buf, sel)
|
||||
|
|
@ -2153,8 +2202,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
});
|
||||
if let Some(text) = picked {
|
||||
let edit_id = egui::Id::new(("expr-edit", uid));
|
||||
let buf_id = egui::Id::new(("expr-buf", uid));
|
||||
let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
|
||||
let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
|
||||
.and_then(|st| st.cursor.char_range())
|
||||
|
|
@ -2171,8 +2218,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ui.set_clip_rect(right_clip);
|
||||
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a));
|
||||
let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
|
||||
let buf_id = egui::Id::new(("expr-buf", uid));
|
||||
let edit_id = egui::Id::new(("expr-edit", uid));
|
||||
let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
|
||||
if let Some(after) = ctx.data_mut(|d| d.remove_temp::<usize>(expr_refocus_id(*uid))) {
|
||||
// A pick whip just put a reference in: editing goes on in the new text,
|
||||
|
|
@ -2185,8 +2230,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// Settings ▸ Scripting & Expressions ▸ Expressions Editor.
|
||||
let sp = app.session.prefs.scripting.clone();
|
||||
let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, (*lines).clamp(1, 8), &t);
|
||||
let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
|
||||
// Its bottom edge drags to set its height in whole lines, as in After Effects.
|
||||
let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
|
||||
let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
|
||||
if eresp.hovered() || eresp.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
}
|
||||
if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
|
||||
let n = ((py - r.min.y - 6.0) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
|
||||
app.ui.timeline.expr_lines.insert(*uid, n as usize);
|
||||
}
|
||||
let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
|
||||
if commit {
|
||||
resp.surrender_focus();
|
||||
|
|
@ -2300,10 +2356,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if name_resp.clicked() {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
name_resp.context_menu(|ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions));
|
||||
// Value editors.
|
||||
let value = ectx.value(layer, prop);
|
||||
let vx = (cw.switches + 4.0).max(name_x + 120.0);
|
||||
value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
|
||||
let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
|
||||
if !dims.is_empty() {
|
||||
dim_hits.push((*uid, dims));
|
||||
}
|
||||
ui.set_clip_rect(right_clip);
|
||||
// Expression text row hint.
|
||||
if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
|
||||
|
|
@ -2535,6 +2595,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
(1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => {
|
||||
let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}});
|
||||
// On one of its values: that dimension (`position[0]`).
|
||||
if let Some(d) = dim_hits.iter().find(|(u, _)| *u == uid).and_then(|(_, rs)| rs.iter().position(|r| r.contains(ptr))) {
|
||||
params["target"]["dimension"] = json!(d);
|
||||
}
|
||||
if let Some((text, [a, b])) = into {
|
||||
// Into the expression being edited, which goes on being edited.
|
||||
params["expression"] = json!(text);
|
||||
|
|
@ -3180,6 +3244,43 @@ fn kind_name(k: MatteKind) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Is property `uid` the layer's Position (or, separated, its X, Y or Z Position)?
|
||||
pub(crate) fn is_position(layer: &Layer, uid: u64) -> bool {
|
||||
layer.transform().is_some_and(|tr| ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == uid)))
|
||||
}
|
||||
|
||||
/// A property's context menu (right-click its name in the Timeline), as in After Effects.
|
||||
fn prop_menu(
|
||||
ui: &mut egui::Ui,
|
||||
auto: &mut crate::automation::Registry,
|
||||
comp: ItemId,
|
||||
layer: &Layer,
|
||||
prop: &Property,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let (l, uid) = (layer.id.0, prop.uid);
|
||||
let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| {
|
||||
let resp = ui.add(egui::Button::new(label).selected(selected));
|
||||
auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label);
|
||||
if resp.clicked() {
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
item(ui, "reset", "Reset", false, "prop.reset", json!({"layer": l, "prop": uid}));
|
||||
if matches!(prop.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
item(ui, "editValue", "Edit Value…", false, "prop.set", json!({"layer": l, "prop": uid}));
|
||||
}
|
||||
if is_position(layer, uid) {
|
||||
let separated = layer.transform().is_some_and(|tr| tr.get("positionX").is_some());
|
||||
item(ui, "separateDimensions", "Separate Dimensions", separated, "prop.separateDimensions", json!({"layer": l, "value": !separated}));
|
||||
}
|
||||
ui.separator();
|
||||
let (key, label) = if prop.expr.is_some() { ("removeExpression", "Remove Expression") } else { ("addExpression", "Add Expression") };
|
||||
item(ui, key, label, false, "prop.setExpression", json!({"layer": l, "prop": uid}));
|
||||
item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid}));
|
||||
}
|
||||
|
||||
fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) {
|
||||
resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions));
|
||||
}
|
||||
|
|
@ -3269,7 +3370,8 @@ fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Inline value editor for a property row; pushes `prop.set` actions.
|
||||
/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
|
||||
/// multi-dimension value's fields, one per dimension (none for other values).
|
||||
fn value_editor(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
|
|
@ -3279,9 +3381,10 @@ fn value_editor(
|
|||
value: &Value,
|
||||
at: Pos2,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
) -> Vec<Rect> {
|
||||
let t = app.tokens;
|
||||
let uid = prop.uid;
|
||||
let mut dims = vec![];
|
||||
let merge = format!("scrub-{uid}");
|
||||
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})))
|
||||
|
|
@ -3336,6 +3439,7 @@ fn value_editor(
|
|||
let suffix = if pct && d + 1 == n { "%" } else { "" };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
|
||||
dims.push(r);
|
||||
if let Some(nv) = nv {
|
||||
let mut nc = c.clone();
|
||||
// Alt edits one value of a linked pair.
|
||||
|
|
@ -3418,6 +3522,7 @@ fn value_editor(
|
|||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
|
||||
}
|
||||
}
|
||||
dims
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -295,6 +295,32 @@ fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] {
|
|||
}
|
||||
}
|
||||
|
||||
/// The resize cursor over bounding-box handle `handle` of the on-screen box `sq` (corners
|
||||
/// clockwise from the top left, handles as in [`handle_point`]): along the direction the handle
|
||||
/// scales, so it turns with the layer, as in After Effects. A corner points between its two
|
||||
/// edges (the box's diagonal whatever its proportions), an edge handle across its edge.
|
||||
fn handle_cursor(sq: &[Pos2], handle: usize) -> egui::CursorIcon {
|
||||
let at = |k: usize| sq.get(k % 4).copied().unwrap_or(Pos2::ZERO);
|
||||
let dir = if handle < 4 {
|
||||
let c = at(handle);
|
||||
(c - at(handle + 3)).normalized() + (c - at(handle + 1)).normalized()
|
||||
} else {
|
||||
let e = at(handle - 3) - at(handle - 4);
|
||||
vec2(-e.y, e.x)
|
||||
};
|
||||
// Screen y points down: 45° is the south-east diagonal.
|
||||
let a = dir.y.atan2(dir.x).to_degrees().rem_euclid(180.0);
|
||||
if !(22.5..157.5).contains(&a) {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
} else if a < 67.5 {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else if a < 112.5 {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
}
|
||||
}
|
||||
|
||||
/// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer
|
||||
/// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio,
|
||||
/// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer,
|
||||
|
|
@ -748,7 +774,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// Overlays + interaction.
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
|
||||
// Bounding-box handles: (layer, handle, screen point, its resize cursor).
|
||||
let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
|
||||
let mut vertex_hits: Vec<VertexHit> = Vec::new();
|
||||
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
|
||||
let mut paths: Vec<ov::PathInfo> = Vec::new();
|
||||
|
|
@ -815,7 +842,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let hr = Rect::from_center_size(hp, vec2(hs, hs));
|
||||
painter.rect_filled(hr, 0.0, col);
|
||||
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
|
||||
handle_hits.push((l.id, i, hp));
|
||||
handle_hits.push((l.id, i, hp, handle_cursor(&sq, i)));
|
||||
app.auto.add(&format!("viewer.handle.{}.{i}", l.id.0), hr, "handle");
|
||||
}
|
||||
// Anchor point.
|
||||
|
|
@ -1036,8 +1063,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
egui::CursorIcon::Move
|
||||
} else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
|
||||
if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
|
||||
} else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) {
|
||||
*cursor
|
||||
} else {
|
||||
egui::CursorIcon::Default
|
||||
}
|
||||
|
|
@ -1213,7 +1240,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// far enough to count as a drag it may have left a small handle.
|
||||
Tool::Selection => {
|
||||
let cpt = map.to_comp(press);
|
||||
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(press) < HANDLE + 2.0).cloned() {
|
||||
if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() {
|
||||
let layer = comp.layer(lid).cloned();
|
||||
layer.and_then(|layer| {
|
||||
let tr = layer.transform()?;
|
||||
|
|
|
|||
|
|
@ -733,7 +733,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
|
|||
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
|
||||
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
|
||||
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
|
||||
let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click());
|
||||
app.auto.add("viewer.timecode", tr, "Current time");
|
||||
crate::panels::toggle_time_display(app, &resp);
|
||||
// Right: render time.
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
if tr.max.x + 50.0 < bar.max.x {
|
||||
|
|
|
|||
|
|
@ -413,6 +413,10 @@ pub struct TimelineState {
|
|||
/// Properties whose inline expression editor is collapsed.
|
||||
#[serde(default)]
|
||||
pub expr_closed: BTreeSet<u64>,
|
||||
/// Inline expression editors given a height (in lines) by dragging their bottom edge;
|
||||
/// the others grow with their text, up to 8 lines.
|
||||
#[serde(default)]
|
||||
pub expr_lines: BTreeMap<u64, usize>,
|
||||
/// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default)
|
||||
/// was turned off.
|
||||
#[serde(default)]
|
||||
|
|
@ -462,6 +466,7 @@ impl Default for TimelineState {
|
|||
graph_reference: false,
|
||||
graph_transform_box: true,
|
||||
expr_closed: BTreeSet::new(),
|
||||
expr_lines: BTreeMap::new(),
|
||||
unlinked: BTreeSet::new(),
|
||||
columns: default_tl_columns(),
|
||||
source_name: false,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,49 @@ use crate::automation::Registry;
|
|||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// "Hot text": a blue number you drag horizontally to scrub, click to type.
|
||||
/// The [`egui::UiStackInfo`] tag of a dock panel's `Ui`: Tab moves between the hot-text fields
|
||||
/// of one panel.
|
||||
pub const PANEL_TAG: &str = "effectcraft.panel";
|
||||
|
||||
/// A panel's hot-text fields in drawing order (this pass's and the last one's), and a Tab
|
||||
/// pressed while typing in one: (that field, Shift held, the pass).
|
||||
#[derive(Clone, Default)]
|
||||
struct TabOrder {
|
||||
pass: u64,
|
||||
current: Vec<egui::Id>,
|
||||
last: Vec<egui::Id>,
|
||||
pending: Option<(egui::Id, bool, u64)>,
|
||||
}
|
||||
|
||||
fn tab_order_id(ui: &Ui) -> egui::Id {
|
||||
let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id);
|
||||
egui::Id::new(("hot-tab-order", panel))
|
||||
}
|
||||
|
||||
/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it
|
||||
/// (Shift+Tab: after it) moves the typing here.
|
||||
fn tab_stop(ui: &Ui, id: egui::Id) -> bool {
|
||||
let key = tab_order_id(ui);
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| {
|
||||
let o = d.get_temp_mut_or_default::<TabOrder>(key);
|
||||
if o.pass != pass {
|
||||
o.last = std::mem::take(&mut o.current);
|
||||
o.pass = pass;
|
||||
}
|
||||
o.current.push(id);
|
||||
let Some((from, back, at)) = o.pending else { return false };
|
||||
let i = o.last.iter().position(|f| *f == from);
|
||||
let next = i.and_then(|i| if back { i.checked_sub(1) } else { i.checked_add(1) }).and_then(|i| o.last.get(i)).copied();
|
||||
if next == Some(id) || next.is_none() || pass > at.saturating_add(2) {
|
||||
o.pending = None;
|
||||
}
|
||||
next == Some(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
|
||||
/// while typing commits and types in the panel's next / previous one, as in After Effects.
|
||||
/// Returns (response, Some(new value) when changed, drag finished).
|
||||
pub fn hot_number_at(
|
||||
ui: &mut Ui,
|
||||
|
|
@ -20,16 +62,28 @@ pub fn hot_number_at(
|
|||
t: &Tokens,
|
||||
) -> (Rect, Option<f64>, bool) {
|
||||
let editing_id = id.with("editing");
|
||||
let editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
|
||||
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
|
||||
if tab_stop(ui, id) && editing.is_none() {
|
||||
editing = Some(format!("{value:.decimals$}"));
|
||||
}
|
||||
let text = format!("{value:.decimals$}{suffix}");
|
||||
let galley = ui.painter().layout_no_wrap(text, Tokens::ui(12.0), t.hot_text);
|
||||
let rect = Rect::from_min_size(rect_min, galley.size() + vec2(4.0, 4.0));
|
||||
if let Some(mut buf) = editing {
|
||||
let er = Rect::from_min_size(rect_min - vec2(2.0, 1.0), vec2((galley.size().x + 24.0).max(54.0), 18.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er));
|
||||
let r = child.add(egui::TextEdit::singleline(&mut buf).desired_width(er.width()).font(Tokens::ui(12.0)).margin(egui::Margin::symmetric(2, 1)));
|
||||
// (Tab stays with the field: it moves to the next hot-text field, not the next widget.)
|
||||
let r = child.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(id.with("text"))
|
||||
.desired_width(er.width())
|
||||
.font(Tokens::ui(12.0))
|
||||
.margin(egui::Margin::symmetric(2, 1))
|
||||
.lock_focus(true),
|
||||
);
|
||||
let tab = r.has_focus().then(|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(i.modifiers.shift))).flatten();
|
||||
let mut out = None;
|
||||
if r.lost_focus() {
|
||||
if r.lost_focus() || tab.is_some() {
|
||||
let txt = buf.trim().trim_end_matches(suffix).trim().replace(',', ".");
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
&& let Ok(v) = txt.parse::<f64>()
|
||||
|
|
@ -38,6 +92,11 @@ pub fn hot_number_at(
|
|||
out = Some(v.clamp(range.0, range.1));
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<String>(editing_id));
|
||||
if let Some(back) = tab {
|
||||
r.surrender_focus();
|
||||
let (key, pass) = (tab_order_id(ui), ui.ctx().cumulative_pass_nr());
|
||||
ui.data_mut(|d| d.get_temp_mut_or_default::<TabOrder>(key).pending = Some((id, back, pass)));
|
||||
}
|
||||
} else {
|
||||
keep_focus(ui.ctx(), &r, &buf);
|
||||
ui.data_mut(|d| d.insert_temp(editing_id, buf));
|
||||
|
|
@ -97,11 +156,13 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s
|
|||
|
||||
/// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it
|
||||
/// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value.
|
||||
/// (Only then is the focus requested: a request resets what keys the field keeps for itself,
|
||||
/// such as Tab with [`egui::TextEdit::lock_focus`].)
|
||||
pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) {
|
||||
if !ctx.memory(|m| m.has_focus(field.id)) {
|
||||
select_all(ctx, field.id, text);
|
||||
field.request_focus();
|
||||
}
|
||||
field.request_focus();
|
||||
}
|
||||
|
||||
/// Select all of the text field `id`'s `text`, so typing replaces it.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Right-click actions in the blank Project and Timeline areas.
|
||||
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
|
|
@ -131,3 +131,67 @@ fn timeline_blank_menu_opens_composition_settings() {
|
|||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
|
||||
}
|
||||
|
||||
/// #407: right-clicking a property's name in the Timeline opens After Effects' property menu:
|
||||
/// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or
|
||||
/// Remove Expression and Add Property to Essential Graphics.
|
||||
#[test]
|
||||
fn timeline_property_names_have_the_property_menu() {
|
||||
let mut h = harness();
|
||||
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
let props =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone();
|
||||
let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid;
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer);
|
||||
let transform = props(&h).sub("transform").unwrap().uid;
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.run_steps(3);
|
||||
let menu = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| {
|
||||
let p = rect(h, &format!("timeline.prop.{uid}.name")).center();
|
||||
right_click(h, p);
|
||||
};
|
||||
let choose = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, entry: &str| {
|
||||
let p = rect(h, &format!("timeline.prop.{uid}.menu.{entry}")).center();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
};
|
||||
// Reset: Rotation's default.
|
||||
let rotation = uid(&h, "transform/rotation");
|
||||
menu(&mut h, rotation);
|
||||
assert!(h.query_by_label("Edit Value…").is_some());
|
||||
assert!(h.query_by_label("Separate Dimensions").is_none(), "only Position separates");
|
||||
choose(&mut h, rotation, "reset");
|
||||
assert_eq!(props(&h).prop("transform/rotation").unwrap().value.as_f64(), 0.0);
|
||||
// Separate Dimensions on Position; X Position's menu has it too, and joins them again.
|
||||
let position = uid(&h, "transform/position");
|
||||
menu(&mut h, position);
|
||||
choose(&mut h, position, "separateDimensions");
|
||||
assert!(props(&h).prop("transform/positionX").is_some(), "separated");
|
||||
let x = uid(&h, "transform/positionX");
|
||||
menu(&mut h, x);
|
||||
choose(&mut h, x, "separateDimensions");
|
||||
assert!(props(&h).prop("transform/positionX").is_none(), "joined");
|
||||
// Add Expression, then Remove Expression.
|
||||
let opacity = uid(&h, "transform/opacity");
|
||||
menu(&mut h, opacity);
|
||||
choose(&mut h, opacity, "addExpression");
|
||||
assert_eq!(props(&h).prop("transform/opacity").unwrap().expr.as_ref().map(|e| e.text.clone()).as_deref(), Some("transform.opacity"));
|
||||
menu(&mut h, opacity);
|
||||
choose(&mut h, opacity, "removeExpression");
|
||||
assert!(props(&h).prop("transform/opacity").unwrap().expr.is_none());
|
||||
// Add Property to Essential Graphics.
|
||||
menu(&mut h, opacity);
|
||||
choose(&mut h, opacity, "essentialGraphics");
|
||||
let comp = h.state().session.active_comp_id().unwrap();
|
||||
assert_eq!(h.state().session.project.comp(comp).unwrap().essential.as_ref().map_or(0, |e| e.controls.len()), 1);
|
||||
// Edit Value… opens the value dialog.
|
||||
let scale = uid(&h, "transform/scale");
|
||||
menu(&mut h, scale);
|
||||
choose(&mut h, scale, "editValue");
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Form));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,3 +127,251 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() {
|
|||
whip(&mut h);
|
||||
assert_eq!(expr(&h), "transform.rotation");
|
||||
}
|
||||
|
||||
/// #363: dropped on one of a property's values the expression pick whip picks that dimension
|
||||
/// (`position[1]`), as in After Effects; on its name, the whole property.
|
||||
#[test]
|
||||
fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() {
|
||||
let (mut h, _) = harness();
|
||||
let (layer, opacity, position, transform) = {
|
||||
let s = &mut h.state_mut().session;
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
|
||||
(l, tr.get("opacity").unwrap().uid, tr.get("position").unwrap().uid, tr.uid)
|
||||
};
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer);
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.run_steps(3);
|
||||
let expr = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(opacity).unwrap().expr.as_ref().unwrap().text.clone()
|
||||
};
|
||||
let whip_to = |h: &mut Harness<'_, EffectcraftApp>, target: &str| {
|
||||
let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, target));
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
};
|
||||
whip_to(&mut h, &format!("timeline.prop.{position}.value.1"));
|
||||
assert_eq!(expr(&h), "transform.position[1]");
|
||||
whip_to(&mut h, &format!("timeline.prop.{position}.name"));
|
||||
assert_eq!(expr(&h), "transform.position[0]", "the whole Position into one value: its first, as before");
|
||||
}
|
||||
|
||||
/// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls
|
||||
/// them (After Effects), so a property out of view can be picked.
|
||||
#[test]
|
||||
fn the_pick_whip_scrolls_the_timeline_at_its_edges() {
|
||||
let (mut h, _) = harness();
|
||||
let (top, opacity, transform) = {
|
||||
let s = &mut h.state_mut().session;
|
||||
for i in 0..40 {
|
||||
s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap();
|
||||
}
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Top", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
|
||||
(l, tr.get("opacity").unwrap().uid, tr.uid)
|
||||
};
|
||||
h.state_mut().ui.timeline.open_layers.insert(top);
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.run_steps(3);
|
||||
let scroll = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.scroll_y;
|
||||
let rows = h.state().auto.find("timeline.scroll").unwrap().rect;
|
||||
let from = center(&h, &format!("timeline.prop.{opacity}.pickWhip"));
|
||||
let (bottom, upper) = (pos2(from.x, rows[1] + rows[3] - 2.0), pos2(from.x, rows[1] + 2.0));
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (bottom - from) * (k as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(20);
|
||||
let down = scroll(&h);
|
||||
assert!(down > 50.0, "held at the bottom edge the layers scroll up: {down}");
|
||||
h.input_mut().events.push(Event::PointerMoved(upper));
|
||||
h.run_steps(10);
|
||||
assert!(scroll(&h) < down, "and at the top edge back down: {} after {down}", scroll(&h));
|
||||
h.input_mut().events.push(Event::PointerMoved(pos2(from.x, rows[1] + rows[3] / 2.0)));
|
||||
h.run_steps(2);
|
||||
let still = scroll(&h);
|
||||
h.run_steps(10);
|
||||
assert_eq!(scroll(&h), still, "away from the edges it stays");
|
||||
h.input_mut().events.push(Event::PointerButton { pos: upper, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// #371: the inline expression field grows with the text being typed (it used to keep the
|
||||
/// saved text's height until committed), and its bottom edge drags to any height in lines.
|
||||
#[test]
|
||||
fn the_expression_field_grows_while_editing_and_drags_taller() {
|
||||
let (mut h, _) = harness();
|
||||
let (layer, opacity, transform) = {
|
||||
let s = &mut h.state_mut().session;
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
|
||||
(l, tr.get("opacity").unwrap().uid, tr.uid)
|
||||
};
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer);
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.run_steps(3);
|
||||
let id = format!("timeline.prop.{opacity}.expression");
|
||||
let height = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find(&id).unwrap().rect[3];
|
||||
let line = h.state().tokens.row_h;
|
||||
let one = height(&h);
|
||||
let p = center(&h, &id);
|
||||
click(&mut h, p, 1);
|
||||
key(&mut h, Key::End);
|
||||
for more in ["+ 1", "+ 2"] {
|
||||
key(&mut h, Key::Enter);
|
||||
type_text(&mut h, more);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!((height(&h) - one - 2.0 * line).abs() < 0.5, "three lines while editing: {} from {one}", height(&h));
|
||||
// Its bottom edge: dragged down 4 lines, then up past the top (one line).
|
||||
let edge_id = format!("timeline.prop.{opacity}.expressionHeight");
|
||||
let drag_edge = |h: &mut Harness<'_, EffectcraftApp>, dy: f32| {
|
||||
let edge = center(h, &edge_id);
|
||||
let to = edge + egui::vec2(0.0, dy);
|
||||
h.input_mut().events.push(Event::PointerMoved(edge));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(edge + (to - edge) * (k as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
};
|
||||
drag_edge(&mut h, 4.0 * line);
|
||||
assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&7));
|
||||
assert!((height(&h) - one - 6.0 * line).abs() < 0.5, "{}", height(&h));
|
||||
drag_edge(&mut h, -20.0 * line);
|
||||
assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1));
|
||||
}
|
||||
|
||||
fn key_with(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// #361: Tab in a value being typed commits it and types in the next value (the next dimension,
|
||||
/// then the next property's), Shift+Tab the previous, in the Timeline and in Effect Controls,
|
||||
/// as in After Effects. It used to drop the keyboard instead.
|
||||
#[test]
|
||||
fn tab_commits_a_value_and_moves_to_the_next() {
|
||||
let (mut h, _) = harness();
|
||||
let (layer, transform) = {
|
||||
let s = &mut h.state_mut().session;
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
for fx in ["Point Control", "Slider Control"] {
|
||||
s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap();
|
||||
}
|
||||
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().uid;
|
||||
(l, tr)
|
||||
};
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer);
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls);
|
||||
h.run_steps(3);
|
||||
let props =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone();
|
||||
let value = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().value.components();
|
||||
let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid;
|
||||
let (position, scale) = (uid(&h, "transform/position"), uid(&h, "transform/scale"));
|
||||
let none = Modifiers::NONE;
|
||||
// Timeline: Position X, Tab, Position Y, Tab, Scale (linked).
|
||||
let p = center(&h, &format!("timeline.prop.{position}.value.0"));
|
||||
click(&mut h, p, 1);
|
||||
type_text(&mut h, "100");
|
||||
key_with(&mut h, Key::Tab, none);
|
||||
type_text(&mut h, "50");
|
||||
key_with(&mut h, Key::Tab, none);
|
||||
type_text(&mut h, "200");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(value(&h, "transform/position")[..2], [100.0, 50.0]);
|
||||
assert_eq!(value(&h, "transform/scale")[..2], [200.0, 200.0]);
|
||||
assert!(!h.ctx.egui_wants_keyboard_input(), "Enter ends the typing");
|
||||
// Shift+Tab: from Scale X back to Position Y.
|
||||
let p = center(&h, &format!("timeline.prop.{scale}.value.0"));
|
||||
click(&mut h, p, 1);
|
||||
type_text(&mut h, "120");
|
||||
key_with(&mut h, Key::Tab, Modifiers::SHIFT);
|
||||
type_text(&mut h, "60");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(value(&h, "transform/position")[..2], [100.0, 60.0]);
|
||||
assert_eq!(value(&h, "transform/scale")[..2], [120.0, 120.0]);
|
||||
// Effect Controls: Point Control's Y, Tab, Slider Control's Slider (the next effect).
|
||||
let point = uid(&h, "effects/#1/point");
|
||||
let p = center(&h, &format!("effectControls.prop.{point}.value.1"));
|
||||
click(&mut h, p, 1);
|
||||
type_text(&mut h, "7");
|
||||
key_with(&mut h, Key::Tab, none);
|
||||
type_text(&mut h, "42");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(value(&h, "effects/#1/point")[1], 7.0);
|
||||
assert_eq!(value(&h, "effects/#2/slider"), [42.0]);
|
||||
}
|
||||
|
||||
/// A click at `p` with `modifiers` held.
|
||||
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// #370: Ctrl/Cmd-click on the current-time display (the Timeline's or the Composition
|
||||
/// panel's) toggles the Time Display Style between Timecode and Frames, one undo step each, as
|
||||
/// in After Effects; under the Timeline's display the other style shows.
|
||||
#[test]
|
||||
fn ctrl_click_on_the_time_display_toggles_timecode_and_frames() {
|
||||
use effectcraft_engine::project::TimeDisplayStyle;
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().session.execute("time.set", json!({"frame": 30})).unwrap();
|
||||
h.run_steps(2);
|
||||
let shown = |h: &Harness<'_, EffectcraftApp>, id: &str| h.state().auto.find(id).unwrap().label.clone();
|
||||
let style = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.settings.time_display;
|
||||
let undo = h.state().session.history.undo.len();
|
||||
let at = center(&h, "timeline.timecode");
|
||||
click_with(&mut h, at, Modifiers::COMMAND);
|
||||
assert_eq!(style(&h), TimeDisplayStyle::Frames);
|
||||
assert_eq!(shown(&h, "timeline.timecode"), "00030");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step");
|
||||
assert!(!h.ctx.egui_wants_keyboard_input(), "no time entry opened");
|
||||
let at = center(&h, "viewer.timecode");
|
||||
click_with(&mut h, at, Modifiers::COMMAND);
|
||||
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
|
||||
assert_eq!(shown(&h, "timeline.timecode"), "0:00:01:06");
|
||||
// A plain click changes nothing.
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
let at = center(&h, "timeline.timecode");
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
|
||||
// Feet + Frames goes to Timecode.
|
||||
h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap();
|
||||
h.run_steps(2);
|
||||
click_with(&mut h, at, Modifiers::COMMAND);
|
||||
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1538,6 +1538,38 @@ fn handle_drags_snap_to_the_comp_edges() {
|
|||
assert!(close(s, [100.0, 100.0]), "{s:?}");
|
||||
}
|
||||
|
||||
/// Each bounding-box handle shows the resize cursor along the direction it scales, turning with
|
||||
/// the layer (#393: every handle showed the north-west / south-east cursor).
|
||||
#[test]
|
||||
fn handle_cursors_follow_each_handle_and_the_layer_rotation() {
|
||||
use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V};
|
||||
let mut h = harness();
|
||||
let box_id = h.state().session.active_comp().unwrap().layers[0].id.0;
|
||||
let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| {
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": box_id, "path": path, "value": v})).unwrap();
|
||||
h.run_steps(2);
|
||||
};
|
||||
let cursors = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
(0..8)
|
||||
.map(|i| {
|
||||
let c = rect(h, &format!("viewer.handle.{box_id}.{i}")).center();
|
||||
h.input_mut().events.push(Event::PointerMoved(c));
|
||||
h.run_steps(2);
|
||||
h.output().platform_output.cursor_icon
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [box_id]})).unwrap();
|
||||
// A wide box: its corners still take the diagonal cursors.
|
||||
set(&mut h, "transform/scale", json!([300, 100, 100]));
|
||||
// Corners clockwise from the top left, then the top, right, bottom and left edges.
|
||||
assert_eq!(cursors(&mut h), [NwSe, NeSw, NwSe, NeSw, V, H, V, H]);
|
||||
set(&mut h, "transform/rotation", json!(90));
|
||||
assert_eq!(cursors(&mut h), [NeSw, NwSe, NeSw, NwSe, H, V, H, V], "turned a quarter");
|
||||
set(&mut h, "transform/rotation", json!(45));
|
||||
assert_eq!(cursors(&mut h), [V, H, V, H, NeSw, NwSe, NeSw, NwSe], "turned an eighth");
|
||||
}
|
||||
|
||||
/// The arrow keys over the Composition panel nudge the selected layer 1 pixel at the viewer's
|
||||
/// magnification (half a comp pixel at 200 %), Shift+arrow 10, one undo step each (#290).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -482,6 +482,17 @@ Extension points and the bundled extensions: [plugins.md](plugins.md).
|
|||
step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers).
|
||||
* Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a
|
||||
click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly.
|
||||
* Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time
|
||||
Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`.
|
||||
* `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its
|
||||
expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a
|
||||
drop on that value field does.
|
||||
* A property's menu (right-click its name; entries `timeline.prop.<uid>.menu.<entry>`): Reset
|
||||
(`prop.reset`: no keyframes or expression, and the property's default value, an effect
|
||||
parameter's or a Transform property's), Edit Value… (`prop.set {layer, prop}` without `value`
|
||||
opens the value dialog), Separate Dimensions (Position: `prop.separateDimensions {layer,
|
||||
value}`), Add / Remove Expression (`prop.setExpression {layer, prop}`) and Add Property to
|
||||
Essential Graphics (`essential.addProperty`).
|
||||
|
||||
### History, puppet recording, plug-ins
|
||||
|
||||
|
|
|
|||
|
|
@ -166,12 +166,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
|
||||
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
|
||||
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; dropped on one of a property's values it picks that dimension (`position[0]`), #363; the inline expression field grows while typing and its bottom edge drags it taller or shorter, #371; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 96% | 0.3 | no extruded strokes (G2: the font menus list every installed family with its own styles — before, only a project asking for a font read the system fonts — and `text.fonts` / MCP `list_fonts` list them for agents; M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
|
||||
| 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; right-clicking a property's name in the Timeline opens its menu (Reset, Edit Value…, Separate Dimensions, Add / Remove Expression, Add Property to Essential Graphics), #407; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) |
|
||||
| Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue