mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M5.10: keyframes behave like After Effects: drags never destroy keys, stretched layers, Shift/Ctrl/Ctrl+Alt clicks, locked layers, Hold off
- A drag (keys.move steps sharing a merge key) applies its total offset to the keys as they
were before it (`Session::key_move`, `prop::shift_keys`): dragging over another key of the
property no longer deletes it; only the drop position replaces a key, as in After Effects.
- Keys of time-stretched layers move with the pointer: offsets are applied in comp time and
snapped to comp frames (drags and Alt+Arrow nudges; they moved by the layer-time distance).
- Shift+click toggles a key in or out of the selection (`keys.select {toggle}`); Ctrl+click
switches Linear ↔ Auto Bezier and Ctrl+Alt+click toggles Hold.
- Keys of locked layers can't be selected (clicks, marquee, Select All Keyframes), so nothing
edits them.
- Toggle Hold off returns an eased key to Bezier (it always restored Linear).
- Shift-dragging a key snaps to everything the current time snaps to — other keys, layer in /
out points, layer and comp markers, the work area — and a tooltip shows the key's time and how
many frames it moved.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3b6b390f68
commit
9929b5f9ac
6 files changed
+262
-28
No files matched your search
@@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::{edit_keys, prop_ref};
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
|
||||
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
@@ -145,6 +145,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time })));
|
||||
}
|
||||
}
|
||||
// Keys of locked layers can't be selected.
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
@@ -153,11 +155,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?;
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let d = Tick(comp.frame_duration().0 * frames);
|
||||
edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| {
|
||||
*t += d;
|
||||
true
|
||||
})
|
||||
let d = Tick(comp.frame_duration().0.saturating_mul(frames));
|
||||
super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
|
||||
|
||||
@@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
|
||||
}
|
||||
}
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
// Keys of locked layers can't be selected (so nothing edits them).
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
// Shift+click: in and out of the selection.
|
||||
for k in sel {
|
||||
match s.state.selected_keys.iter().position(|x| *x == k) {
|
||||
Some(i) => {
|
||||
s.state.selected_keys.remove(i);
|
||||
}
|
||||
None => s.state.selected_keys.push(k),
|
||||
}
|
||||
}
|
||||
} else if b_p(p, "add").unwrap_or(false) {
|
||||
for k in sel {
|
||||
if !s.state.selected_keys.contains(&k) {
|
||||
s.state.selected_keys.push(k);
|
||||
@@ -400,14 +412,72 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
||||
})
|
||||
}
|
||||
|
||||
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
|
||||
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
|
||||
/// locked layers stay. A key landing on another key of its property replaces it.
|
||||
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
// A drag (steps with one merge key) starts again from the keys before it each step.
|
||||
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
|
||||
_ => (s.state.selected_keys.clone(), d, None),
|
||||
};
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
|
||||
s.edit(label, merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &from {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
|
||||
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
|
||||
if l.switches.locked {
|
||||
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = l.props.find_mut(uid) else { continue };
|
||||
// Take the moving keys out, then put them back at their new times.
|
||||
let mut moved = vec![];
|
||||
for (old, new) in &moves {
|
||||
if let Some(i) = key_at(&pr.keys, *old) {
|
||||
let mut k = pr.keys.remove(i);
|
||||
k.time = *new;
|
||||
moved.push(k);
|
||||
}
|
||||
}
|
||||
for k in moved {
|
||||
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
|
||||
Ok(j) => pr.keys[j] = k,
|
||||
Err(j) => pr.keys.insert(j, k),
|
||||
}
|
||||
}
|
||||
// Roving keys follow their neighbours: keep the selection on them.
|
||||
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
|
||||
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
|
||||
for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
|
||||
if let Some(i) = before.iter().position(|t| *t == r.time) {
|
||||
r.time = pr.keys[i].time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_keys = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
|
||||
let fr = s.active_comp().map(|c| c.frame_rate);
|
||||
edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| {
|
||||
let nt = *t + d;
|
||||
*t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt);
|
||||
true
|
||||
})
|
||||
shift_keys(s, "Move Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
|
||||
let k = &mut keys[i];
|
||||
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
|
||||
// Off again: back to Bezier when the key eases (its other side, auto / continuous, or
|
||||
// an outgoing ease), else Linear.
|
||||
let bezier = k.auto_bezier || k.continuous || k.in_interp == Interp::Bezier || !k.out_ease.is_empty();
|
||||
k.out_interp = match k.out_interp {
|
||||
Interp::Hold if bezier => Interp::Bezier,
|
||||
Interp::Hold => Interp::Linear,
|
||||
_ => Interp::Hold,
|
||||
};
|
||||
false
|
||||
})
|
||||
}
|
||||
@@ -667,7 +744,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
convert_expr_to_keys
|
||||
),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
|
||||
cmd!(
|
||||
"keys.select",
|
||||
"Select Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
|
||||
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
|
||||
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
|
||||
|
||||
@@ -134,6 +134,17 @@ pub struct KeyRef {
|
||||
pub time: Tick,
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it
|
||||
/// started from and the total offset so far, so every step is applied to the keys as they were
|
||||
/// before the drag. A key passed over on the way is never overwritten; only the drop position
|
||||
/// counts, as in After Effects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeyMove {
|
||||
pub merge: String,
|
||||
pub from: Vec<KeyRef>,
|
||||
pub total: Tick,
|
||||
}
|
||||
|
||||
/// Keyframes of one property on the keyframe clipboard.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KeyClip {
|
||||
@@ -373,6 +384,8 @@ pub struct Session {
|
||||
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
|
||||
/// (headless sessions don't query it, see [`Session::memory_tick`]).
|
||||
pub sys_memory: Option<sysinfo::SysMemory>,
|
||||
/// The keyframe drag in progress (`keys.move` with a merge key).
|
||||
pub key_move: Option<KeyMove>,
|
||||
/// Reads the system's memory in the background for [`Session::memory_tick`].
|
||||
pub memory_watch: sysinfo::MemoryWatch,
|
||||
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
|
||||
@@ -467,6 +480,7 @@ impl Default for Session {
|
||||
plugin_loader: None,
|
||||
script_ui: scriptui::ScriptUi::default(),
|
||||
sys_memory: None,
|
||||
key_move: None,
|
||||
memory_watch: Default::default(),
|
||||
applied_nested_switches: None,
|
||||
offload: None,
|
||||
|
||||
@@ -863,3 +863,78 @@ fn new_comp_from_selection_survives_hostile_sequence_numbers() {
|
||||
s.state.project_selection = vec![a, b];
|
||||
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err());
|
||||
}
|
||||
|
||||
fn key_times(s: &Session, l: u64) -> Vec<f64> {
|
||||
layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) {
|
||||
for (i, t) in times.iter().enumerate() {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_key_past_another_keeps_it() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[1.0, 2.0]);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
// One drag, frame by frame, from 1 s over the 2 s key to 3 s.
|
||||
for _ in 0..60 {
|
||||
s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there");
|
||||
// Dropped exactly on a key: it replaces it, as in After Effects.
|
||||
s.history.merge_key = None;
|
||||
s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![2.0]);
|
||||
// The whole drag was one undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_of_a_stretched_layer_move_with_the_pointer() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap();
|
||||
assert_eq!(layer(&s, a).stretch, 200.0);
|
||||
let comp_time = |s: &Session, i: usize| {
|
||||
let l = layer(s, a);
|
||||
l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds()
|
||||
};
|
||||
let before = comp_time(&s, 1);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1));
|
||||
s.execute("keys.nudge", json!({"frames": 3})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t});
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1, "toggled out");
|
||||
// Hold off returns an eased key to Bezier.
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier);
|
||||
// Locked: its keys can't be selected.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! bar, layer duration bars, keyframes, the CTI, and the value graph editor.
|
||||
|
||||
use effectcraft_engine::color::BlendMode;
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::keyframe::{Interp, Value};
|
||||
use effectcraft_engine::project::{Comp, GroupKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
@@ -1874,14 +1874,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
|
||||
if kresp.clicked() || kresp.drag_started() {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if !ks || shift {
|
||||
actions.push((
|
||||
"keys.select".into(),
|
||||
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "add": shift}),
|
||||
));
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]});
|
||||
if kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
|
||||
actions.push(("keys.select".into(), this_key.clone()));
|
||||
if mods.alt {
|
||||
actions.push(("keys.toggleHold".into(), json!({})));
|
||||
} else {
|
||||
let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
// Shift+click: in or out of the selection.
|
||||
let mut p = this_key.clone();
|
||||
p["toggle"] = json!(true);
|
||||
actions.push(("keys.select".into(), p));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !ks {
|
||||
let mut p = this_key.clone();
|
||||
p["add"] = json!(mods.shift);
|
||||
actions.push(("keys.select".into(), p));
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
ui_actions.push(UiAct::SetTime(ct));
|
||||
@@ -2047,11 +2059,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Some(ptr) => {
|
||||
let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps;
|
||||
if shift {
|
||||
let snaps = [time, comp.work_area.0, comp.work_area.1].into_iter().chain(comp.markers.iter().map(|m| m.time)).map(Tick::seconds);
|
||||
let reach = 8.0 / pps;
|
||||
if let Some(s) = snaps.filter(|s| (s - target).abs() <= reach).min_by(|a, b| (a - target).abs().total_cmp(&(b - target).abs())) {
|
||||
target = s;
|
||||
}
|
||||
// The current time and whatever it snaps to (keys, layer ends, markers), but
|
||||
// not the moving keys themselves.
|
||||
let moving: Vec<f64> =
|
||||
app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
|
||||
let mut cands = snap_candidates(&comp, &rows);
|
||||
cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
|
||||
cands.push(time.seconds());
|
||||
target = snap_time(&cands, target, 8.0 / pps);
|
||||
}
|
||||
let total = ((target - kd.anchor) / fd).round() as i64;
|
||||
if total != kd.applied {
|
||||
@@ -2060,6 +2075,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
// Where the grabbed key is and how far the keys moved (After Effects shows this
|
||||
// in the Info panel).
|
||||
let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd);
|
||||
let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied);
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
|
||||
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
|
||||
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
|
||||
fg.rect_filled(r, 3.0, t.panel_bg);
|
||||
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
|
||||
app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at));
|
||||
}
|
||||
None => {
|
||||
ctx.data_mut(|d| d.remove::<KeyDrag>(key_drag_id()));
|
||||
|
||||
@@ -54,6 +54,7 @@ fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: eg
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
@@ -112,3 +113,37 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]);
|
||||
}
|
||||
|
||||
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_and_ctrl_click_switches_interpolation() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
let shift = egui::Modifiers { shift: true, ..Default::default() };
|
||||
click_with(&mut h, ks[0], Default::default());
|
||||
click_with(&mut h, ks[1], shift);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 2, "Shift+click adds");
|
||||
click_with(&mut h, ks[1], shift);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 1, "and takes out again");
|
||||
let key =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone();
|
||||
// Ctrl+click: Linear → Auto Bezier → Linear.
|
||||
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
|
||||
assert!(key(&h).auto_bezier, "Auto Bezier");
|
||||
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
|
||||
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Linear);
|
||||
// Ctrl+Alt+click: Hold.
|
||||
click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND });
|
||||
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold);
|
||||
}
|
||||
Reference in new issue
Block a user