M13.20: every Timeline property shortcut — double presses AA (Material Options), PP (paint / Roto Brush / Puppet), SS (selected properties), RR (Time Remap), TT (Mask Opacity), MM (all mask properties; a single M now shows Mask Path), FF (missing effects), EE (expressions) next to LL and UU; Alt+Shift+A/P/S/R/T add or remove a keyframe at the current time on every selected layer in one undo step (keys.toggleTransform: separated X/Y/Z Position, Orientation and X/Y/Z Rotation on 3D layers); Ctrl/Cmd+` twirls the selected layers open or closed (from a reveal, their full tree)

This commit is contained in:
bflatastic 2026-10-04 01:10:33 +07:00
parent aef72378f8
commit f9990f7387
5 changed files with 284 additions and 26 deletions

View File

@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, merge_p, str_p};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd, query};
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
@ -170,22 +170,61 @@ fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleKey")?;
with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| {
if let Some(i) = key_at(&pr.keys, lt) {
if pr.keys.len() == 1 {
pr.value = pr.keys[0].value.clone();
}
pr.keys.remove(i);
Ok(json!(false))
} else {
let mut k = Keyframe::new(lt, pr.value_at(lt));
if pr.hold_only {
k = k.hold();
}
set_key(&mut pr.keys, k);
Ok(json!(true))
with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| Ok(json!(toggle_key_at(pr, lt))))
}
/// Add a keyframe at layer time `lt` (true), or remove the one there (false).
fn toggle_key_at(pr: &mut Property, lt: Tick) -> bool {
if let Some(i) = key_at(&pr.keys, lt) {
if pr.keys.len() == 1 {
pr.value = pr.keys[0].value.clone();
}
})
pr.keys.remove(i);
false
} else {
let mut k = Keyframe::new(lt, pr.value_at(lt));
if pr.hold_only {
k = k.hold();
}
set_key(&mut pr.keys, k);
true
}
}
/// Alt+Shift+A / P / S / R / T: add or remove a keyframe at the current time on that Transform
/// property of every selected layer, in one undo step. Position covers separated X / Y / Z;
/// Rotation on a 3D layer covers Orientation and X / Y / Z Rotation.
fn toggle_transform_key(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.toggleTransform";
let which = str_p(p, "prop").ok_or_else(|| bad(C, "missing `prop` (anchor | position | scale | rotation | opacity)"))?.to_string();
let (cid, layers) = layers_p(s, p)?;
let t = s.time_of(cid);
let mut added = vec![];
s.edit("Add/Remove Keyframe", None, |proj, _| {
for lid in &layers {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let three = l.is_3d();
let Some(tr) = l.props.sub_mut("transform") else { continue };
let ids: Vec<&str> = match which.as_str() {
"anchor" => vec!["anchor"],
"position" if tr.get("positionX").is_some() => vec!["positionX", "positionY", "positionZ"],
"position" => vec!["position"],
"scale" => vec!["scale"],
"rotation" if three => vec!["orientation", "rotationX", "rotationY", "rotation"],
"rotation" => vec!["rotation"],
"opacity" => vec!["opacity"],
other => return Err(bad(C, format!("unknown prop `{other}` (anchor | position | scale | rotation | opacity)"))),
};
for id in ids.into_iter().filter(|id| three || *id != "positionZ") {
if let Some(pr) = tr.get_mut(id) {
added.push(json!({"layer": lid.0, "prop": id, "key": toggle_key_at(pr, lt)}));
}
}
}
Ok(())
})?;
Ok(Value::Array(added))
}
fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
@ -607,6 +646,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
cmd!(
"keys.toggleTransform",
"Add or Remove Transform Keyframe",
[],
None,
"{layers?, prop: anchor|position|scale|rotation|opacity} (Alt+Shift+A/P/S/R/T)",
has_layers,
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.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),

View File

@ -594,3 +594,27 @@ fn arrange_above_moves_layers_to_a_place_in_the_stack() {
assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "B"})).is_err());
assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "Nope"})).is_err());
}
#[test]
fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotation() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "K", "width": 100, "height": 100, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let keys = |s: &Session, l: u64, path: &str| prop(s, l, path).keys.len();
// Both selected layers get an Opacity key, in one undo step; again removes them.
let steps = s.history.undo.len();
s.execute("keys.toggleTransform", json!({"prop": "opacity"})).unwrap();
assert_eq!((keys(&s, a, "transform/opacity"), keys(&s, b, "transform/opacity")), (1, 1));
assert_eq!(s.history.undo.len(), steps + 1);
s.execute("keys.toggleTransform", json!({"prop": "opacity"})).unwrap();
assert_eq!(keys(&s, a, "transform/opacity"), 0);
// A 3D layer's Rotation key covers Orientation and X/Y/Z Rotation.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
s.execute("keys.toggleTransform", json!({"layers": [a], "prop": "rotation"})).unwrap();
for p in ["orientation", "rotationX", "rotationY", "rotation"] {
assert_eq!(keys(&s, a, &format!("transform/{p}")), 1, "{p}");
}
assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err());
}

View File

@ -61,6 +61,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("timeline.reveal.masks", "Reveal Masks", [], Some("M")),
uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")),
uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")),
uic!("timeline.reveal.timeRemap", "Reveal Time Remap", [], None),
uic!("timeline.reveal.expressions", "Reveal Expressions", [], None),
uic!("timeline.reveal.maskOpacity", "Reveal Mask Opacity", [], None),
uic!("timeline.reveal.maskPath", "Reveal Mask Path", [], None),
uic!("timeline.reveal.material", "Reveal Material Options", [], None),
uic!("timeline.reveal.paint", "Reveal Paint, Roto Brush and Puppet", [], None),
uic!("timeline.reveal.selected", "Reveal Selected Properties", [], None),
uic!("timeline.reveal.missingEffects", "Reveal Missing Effects", [], None),
uic!("timeline.reveal.waveform", "Reveal Audio Waveform", [], None),
uic!("timeline.revealAdd.position", "Add Position to Revealed Properties", [], Some("Shift+P")),
uic!("timeline.revealAdd.scale", "Add Scale to Revealed Properties", [], Some("Shift+S")),
@ -77,7 +85,13 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("timeline.search", "Search Timeline", [], None),
uic!("flowchart.options", "Flowchart Options", [], None),
uic!("flowchart.graph", "Flowchart Graph", [], None),
uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")),
uic!("timeline.collapseAll", "Collapse All", [], None),
uic!("timeline.twirlSelected", "Twirl Selected Layers Open / Closed", [], Some("Cmd+`")),
uic!("timeline.keyAt.anchor", "Add or Remove Anchor Point Keyframe", [], Some("Alt+Shift+A")),
uic!("timeline.keyAt.position", "Add or Remove Position Keyframe", [], Some("Alt+Shift+P")),
uic!("timeline.keyAt.scale", "Add or Remove Scale Keyframe", [], Some("Alt+Shift+S")),
uic!("timeline.keyAt.rotation", "Add or Remove Rotation Keyframe", [], Some("Alt+Shift+R")),
uic!("timeline.keyAt.opacity", "Add or Remove Opacity Keyframe", [], Some("Alt+Shift+T")),
uic!("tool.selection", "Selection Tool", [], Some("V")),
uic!("tool.hand", "Hand Tool", [], Some("H")),
uic!("tool.zoom", "Zoom Tool", [], Some("Z")),
@ -110,12 +124,38 @@ pub fn panel_command_id(p: PanelKind) -> String {
/// The reveal shortcuts (P, S, R, T, A, E, M, F, L…). `add` (Shift+key) adds the property to
/// (or removes it from) those already revealed instead of replacing them.
pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) {
// AE's double-press shortcuts: L then L again quickly reveals the Waveform (LL).
let kind = match app.last_reveal.take() {
Some((k, t)) if k == "levels" && kind == "levels" && now - t < 0.6 => "waveform",
_ => kind,
// The double-press shortcuts: the same key again within 0.6 s reveals its second set (LL
// Waveform, TT Mask Opacity, MM all mask properties, EE Expressions, RR Time Remap, AA
// Material Options, PP paint / Roto Brush / Puppet, SS selected properties, FF missing
// effects). A single M shows Mask Path.
let double = |k: &str| -> Option<&'static str> {
Some(match k {
"levels" => "waveform",
"opacity" => "maskOpacity",
"masks" => "masks",
"effects" => "expressions",
"rotation" => "timeRemap",
"anchor" => "material",
"position" => "paint",
"scale" => "selected",
"feather" => "missingEffects",
_ => return None,
})
};
let pressed = kind;
// (With Shift a second press removes the property again instead.)
let again = !add && matches!(app.last_reveal.take(), Some((k, t)) if k == pressed && now - t < 0.6);
app.last_reveal = Some((if again { String::new() } else { pressed.to_string() }, now));
let kind = match (again, double(pressed)) {
(true, Some(d)) => {
// The second press replaces what the first one revealed.
let first = if pressed == "masks" { "maskPath" } else { pressed };
app.ui.timeline.reveal.retain(|k| k != first);
d
}
_ if pressed == "masks" => "maskPath",
_ => pressed,
};
app.last_reveal = Some((kind.to_string(), now));
let tl = &mut app.ui.timeline;
let present = tl.reveal.iter().any(|k| k == kind);
let sel: Vec<u64> = if app.session.state.selected_layers.is_empty() {
@ -208,6 +248,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
reveal(app, k, now, false);
return Ok(Value::Null);
}
if let Some(k) = id.strip_prefix("timeline.keyAt.") {
return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}));
}
if let Some(k) = id.strip_prefix("timeline.revealAdd.") {
reveal(app, k, now, true);
return Ok(json!({"revealed": app.ui.timeline.reveal}));
@ -358,6 +401,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
app.ui.timeline.open_groups.clear();
app.ui.timeline.reveal.clear();
}
// Ctrl+`: twirl the selected layers open (all closed ones) or closed (all open). From a
// reveal shortcut's view it opens their full property trees.
"timeline.twirlSelected" => {
let sel: Vec<u64> = app.session.state.selected_layers.iter().map(|l| l.0).collect();
let tl = &mut app.ui.timeline;
let revealing = !tl.reveal.is_empty();
tl.reveal.clear();
if !revealing && sel.iter().all(|l| tl.open_layers.contains(l)) {
for l in &sel {
tl.open_layers.remove(l);
}
} else {
tl.open_layers.extend(sel);
}
}
"timeline.workAreaBegin" => return app.session.execute("comp.workArea", json!({"set": "begin"})).map_err(|e| e.to_string()),
"timeline.workAreaEnd" => return app.session.execute("comp.workArea", json!({"set": "end"})).map_err(|e| e.to_string()),
"timeline.moveInToTime" => return timing(app, "moveInToTime"),

View File

@ -423,6 +423,7 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool {
fn reveal_matches(p: &Property, path_matches: &[&str], kind: &str) -> bool {
match kind {
"animated" => p.is_animated() || p.has_expression(),
"expressions" => p.has_expression(),
_ => path_matches.contains(&p.match_id.as_str()),
}
}
@ -497,8 +498,11 @@ fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])>
"opacity" => ("transform", &["opacity"]),
"anchor" => ("transform", &["anchor"]),
"feather" => ("masks", &["feather"]),
"maskPath" => ("masks", &["path"]),
"maskOpacity" => ("masks", &["opacity"]),
"levels" => ("audio", &["levels"]),
"animated" => ("", &[]),
"timeRemap" => ("", &["timeRemap"]),
"animated" | "expressions" => ("", &[]),
_ => return None,
})
}
@ -620,6 +624,28 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
{
group_rows(rows, fx, true);
}
// PP: paint, Roto Brush and Puppet effects; FF: effects whose plug-in is missing.
for (k, keep) in [
("paint", &(|id: &str| matches!(id, "ec.paint.paint" | "ec.matte.rotobrush" | "ec.distort.puppet")) as &dyn Fn(&str) -> bool),
("missingEffects", &|id: &str| effectcraft_engine::effects::find(id).is_none()),
] {
if has(k)
&& let Some(fx) = l.effects()
{
let mut only = fx.clone();
only.children.retain(|c| matches!(c, Node::Group(g) if matches!(&g.kind, GroupKind::Effect { effect } if keep(effect))));
group_rows(rows, &only, true);
}
}
// AA: Material Options (3D layers).
if has("material")
&& let Some(m) = l.props.sub("materialOptions")
&& l.is_3d()
{
let mut only = PropGroup::new(0, "", "");
only.children.push(Node::Group(m.clone()));
group_rows(rows, &only, false);
}
// P/S/R/T/A/F/L/animated: matching properties, in tree order, each once.
let mut wanted = std::collections::BTreeSet::new();
for kind in &tl.reveal {
@ -640,7 +666,12 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
collect_props(&l.props, &mut found, &|p| wanted.contains(&p.uid));
rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
}
if has("props") {
// SS: the selected properties and groups (as Animation ▸ Reveal Properties shows them).
let selected: std::collections::BTreeSet<u64> = app.session.state.selected_props.iter().filter(|(lid, _)| *lid == l.id).map(|(_, u)| *u).collect();
if has("props") || has("selected") {
let picked: std::collections::BTreeSet<u64> =
if has("props") { tl.reveal_props.iter().copied().chain(selected.iter().copied().filter(|_| has("selected"))).collect() } else { selected };
let reveal_props = &picked;
// Animation ▸ Reveal Properties…: the engine picked the uids.
fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet<u64>, out: &mut Vec<&'a PropGroup>) {
for sg in g.groups() {
@ -652,7 +683,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
}
}
let mut groups = vec![];
groups_in(&l.props, &tl.reveal_props, &mut groups);
groups_in(&l.props, reveal_props, &mut groups);
for g in groups {
let open = tl.open_groups.contains(&g.uid);
rows.push(Row {
@ -665,7 +696,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
}
}
let mut found = vec![];
collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && tl.reveal_props.contains(&p.uid) && !wanted.contains(&p.uid));
collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && reveal_props.contains(&p.uid) && !wanted.contains(&p.uid));
rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
}
if has("waveform")

View File

@ -646,3 +646,100 @@ fn timeline_rename_commits_on_enter_and_on_click_away() {
key(&mut h, egui::Key::Escape, Default::default());
assert_eq!(name(&h), "Hand");
}
/// Press `k` twice within one frame (a quick double press: the harness' frames are long).
fn key_twice(h: &mut Harness<'_, EffectcraftApp>, k: egui::Key) {
for _ in 0..2 {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
}
h.run_steps(2);
}
#[test]
fn property_shortcuts_reveal_and_key_transform_properties() {
let mut h = harness();
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.run_steps(2);
let t = |h: &Harness<'_, EffectcraftApp>, p: &str| shown(h, prop_uid(h, "Box", &format!("transform/{p}")));
let only = |h: &Harness<'_, EffectcraftApp>, want: &[&str]| {
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
assert_eq!(t(h, p), want.contains(&p), "{p} shown? want {want:?}");
}
};
let none = egui::Modifiers::default();
// P, then T: one property at a time; Shift+S adds Scale; P again hides.
key(&mut h, egui::Key::P, none);
only(&h, &["position"]);
key(&mut h, egui::Key::T, none);
only(&h, &["opacity"]);
key(&mut h, egui::Key::S, egui::Modifiers::SHIFT);
only(&h, &["scale", "opacity"]);
key(&mut h, egui::Key::A, none);
only(&h, &["anchor"]);
key(&mut h, egui::Key::R, none);
only(&h, &["rotation"]);
// Alt+Shift+P: a Position keyframe at the current time; again removes it.
let pos_keys = |h: &Harness<'_, EffectcraftApp>| {
let id = layer_id(h, "Box");
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().keys.len()
};
let alt_shift = egui::Modifiers { alt: true, shift: true, ..Default::default() };
key(&mut h, egui::Key::P, alt_shift);
assert_eq!(pos_keys(&h), 1);
key(&mut h, egui::Key::P, alt_shift);
assert_eq!(pos_keys(&h), 0);
// U: only keyframed properties (Opacity animated); U again later hides; UU modified ones.
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 0, "value": 50})).unwrap();
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 1, "value": 100})).unwrap();
h.run_steps(2);
key(&mut h, egui::Key::U, none);
only(&h, &["opacity"]);
h.run_steps(40);
key(&mut h, egui::Key::U, none);
only(&h, &[]);
h.state_mut().session.execute("prop.set", json!({"layer": "Box", "path": "transform/rotation", "value": 30})).unwrap();
h.run_steps(40);
key_twice(&mut h, egui::Key::U);
assert!(t(&h, "opacity") && t(&h, "rotation"), "UU: animated and changed properties");
assert!(!t(&h, "scale"), "an untouched property stays hidden");
// Ctrl+`: twirl the selected layer open, then closed.
h.run_steps(40);
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
let id = layer_id(&h, "Box");
assert!(h.state().ui.timeline.open_layers.contains(&id) && h.state().ui.timeline.reveal.is_empty());
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
assert!(!h.state().ui.timeline.open_layers.contains(&id));
}
#[test]
fn double_press_shortcuts_reveal_their_second_set() {
let mut h = harness();
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.state_mut().session.execute("prop.setExpression", json!({"layer": "Box", "path": "transform/scale", "expression": "value"})).unwrap();
h.run_steps(2);
let reveal = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.reveal.clone();
// EE: properties with expressions (Scale), replacing E's effects.
key_twice(&mut h, egui::Key::E);
assert_eq!(reveal(&h), vec!["expressions"]);
assert!(shown(&h, prop_uid(&h, "Box", "transform/scale")));
h.run_steps(40);
// TT: Mask Opacity; MM: every mask property; a single M: Mask Path.
key_twice(&mut h, egui::Key::T);
assert_eq!(reveal(&h), vec!["maskOpacity"]);
h.run_steps(40);
key(&mut h, egui::Key::M, Default::default());
assert_eq!(reveal(&h), vec!["maskPath"]);
h.run_steps(40);
key_twice(&mut h, egui::Key::M);
assert_eq!(reveal(&h), vec!["masks"]);
h.run_steps(40);
// RR Time Remap, AA Material Options, PP paint/puppet, SS selected properties, FF missing effects.
for (k, want) in
[(egui::Key::R, "timeRemap"), (egui::Key::A, "material"), (egui::Key::P, "paint"), (egui::Key::S, "selected"), (egui::Key::F, "missingEffects")]
{
key_twice(&mut h, k);
assert_eq!(reveal(&h), vec![want], "{k:?}{k:?}");
h.run_steps(40);
}
}