Graph Editor: close remaining lock and menu gaps (#1342)

This commit is contained in:
Kristian Myram committed 2026-10-29 21:31:23 +01:00
1 parent 0c4668df85
commit e32eaa03ad
7 files changed
+160 -7

No files matched your search

+11 -3
View File
@@ -89,6 +89,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
for (ti, lid) in targets.iter().enumerate() {
let Some(l) = comp.layer(*lid) else { continue };
if l.switches.locked {
continue;
}
for (ci, c) in clip.iter().enumerate() {
if paired && sources.get(ti) != Some(&c.layer) {
continue;
@@ -110,11 +113,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)"));
}
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
let mut sel: Vec<KeyRef> = st.selected_keys.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect();
let retained = sel.len();
for (lid, uid, ci) in &plan {
let Some(l) = comp.layer_mut(*lid) else { continue };
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
@@ -136,8 +140,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
retime_roving(&mut pr.keys, pr.spatial);
}
let pasted = sel.len().saturating_sub(retained);
st.selected_keys = sel;
Ok(json!(st.selected_keys.len()))
Ok(json!(pasted))
})
}
@@ -175,6 +180,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
if target.switches.locked {
return Err(bad("keys.paste", "the target layer is locked"));
}
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
if path.closed && !order.is_empty() {
order.push(0);
+3 -1
View File
@@ -216,10 +216,12 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
"fill" if fill.is_none() => fill = at("color").map(rgba),
"stroke" if stroke.is_none() => {
stroke = at("color").map(rgba);
width = at("width").map(|v| v.as_f64());
}
_ => {}
}
if width.is_none() && matches!(g.match_id.as_str(), "stroke" | "gstroke") {
width = at("width").map(|v| v.as_f64());
}
});
}
(fill, stroke, width)
+67
View File
@@ -114,6 +114,73 @@ fn spatial_handles_cannot_edit_a_locked_layer() {
assert_eq!(s.project.to_json(), edited);
}
#[test]
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
for path_clip in [false, true] {
for explicit in [false, true] {
let (mut s, layer, opacity) = setup();
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
let source = if path_clip {
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
} else {
opacity
};
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let result = s.execute("keys.paste", params.clone());
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.paste", params).unwrap();
let pasted = s.project.to_json();
assert_ne!(pasted, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
}
#[test]
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
let (mut s, locked, locked_prop) = setup();
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
}
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
s.execute("keys.copy", json!({})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 6);
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
#[test]
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
let (mut s, locked, locked_prop) = setup();
+36
View File
@@ -920,4 +920,40 @@ mod tests {
assert_eq!(size, Some(effectcraft_engine::keyframe::Value::Vec2([400.0, 300.0])));
assert_eq!(fill, effectcraft_engine::keyframe::Value::Color([0.0, 1.0, 0.0, 1.0]), "the Tools bar's Fill, not the default blue");
}
#[test]
fn double_clicked_shapes_keep_the_selected_gradient_stroke_width() {
for kind in ["solid", "linear", "radial"] {
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
s.execute("layer.newShape", json!({"kind": "rect"})).unwrap();
s.execute("shape.fillStroke", json!({"fill": false, "strokeType": kind, "strokeWidth": 9})).unwrap();
let mut app = EffectcraftApp::new(s);
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
frame(&mut app, &ctx, 0.0, vec![]);
frame(&mut app, &ctx, 0.1, vec![]);
assert_eq!(app.session.state.shape_tool.stroke_width, 9.0, "selected {kind} stroke");
let [x, y, w, h] = app.auto.find("tools.Rectangle").unwrap().rect;
let at = egui::pos2(x + w / 2.0, y + h / 2.0);
for (time, pressed) in [(1.0, true), (1.05, false), (1.1, true), (1.15, false)] {
frame(
&mut app,
&ctx,
time,
vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
],
);
}
let comp = app.session.active_comp().unwrap();
assert_eq!(comp.layers.len(), 2);
let layer = comp.layer(app.session.state.selected_layers[0]).unwrap();
let (_, _, width) = effectcraft_engine::commands::shape_stroke::first_paint(layer, effectcraft_engine::time::Tick::ZERO);
assert_eq!(width, Some(9.0), "new {kind} stroke");
let (_, stroke_kind) = effectcraft_engine::commands::shape_stroke::first_paint_kinds(layer, effectcraft_engine::time::Tick::ZERO);
assert_eq!(stroke_kind.id(), kind);
}
}
}
+3
View File
@@ -124,6 +124,9 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue {
return !app.session.is_rendering() && !app.session.project.render_queue.is_empty();
}
if e.command == "file.close" && super::invoke::no_params(&e.params) && app.ui.focused == PanelKind::RenderQueue {
return true;
}
if e.command == "window.panel" {
return true;
}
+4 -3
View File
@@ -159,7 +159,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
})
}
fn no_params(p: &Value) -> bool {
pub(super) fn no_params(p: &Value) -> bool {
p.as_object().is_none_or(|m| m.is_empty())
}
@@ -216,12 +216,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
// Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and
// leaves the active composition open (it used to close the composition next to it).
if id == "file.close" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue {
if id == "file.close" && no_params(&params) && app.ui.focused == PanelKind::RenderQueue {
app.close_panel(PanelKind::RenderQueue);
// The focus goes to a panel that is still there, so the next Close acts on the composition
// instead of on the queue that is gone.
app.ui.focused =
let next =
[PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| app.ui.dock.is_visible(*p)).unwrap_or(PanelKind::Project);
app.show_panel(next);
return Ok(Value::Null);
}
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
+36
View File
@@ -437,6 +437,42 @@ fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() {
assert_eq!(app.session.state.open_comps.len(), 0, "a second Close acts on the composition");
}
#[test]
fn file_close_is_available_for_an_empty_render_queue_without_a_comp() {
for params in [Value::Null, json!({})] {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
assert!(app.session.active_comp().is_none());
app.show_panel(PanelKind::RenderQueue);
assert!(menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled);
invoke(&mut app, &ctx, "file.close", params).unwrap();
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
}
}
#[test]
fn closing_the_render_queue_reveals_the_panel_receiving_focus() {
for maximized in [false, true] {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.close_panel(PanelKind::Timeline);
app.close_panel(PanelKind::Composition);
app.show_panel(PanelKind::EffectControls);
assert!(!app.ui.dock.is_visible(PanelKind::Project));
app.show_panel(PanelKind::RenderQueue);
if maximized {
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap();
app.toggle_maximize(PanelKind::EffectControls);
app.ui.focused = PanelKind::RenderQueue;
assert!(app.ui.dock.is_floating(PanelKind::RenderQueue));
}
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert_eq!(app.ui.focused, PanelKind::Project);
assert!(app.ui.dock.is_visible(app.ui.focused), "focus must belong to a front tab");
assert_eq!(app.ui.maximized, None, "the focused docked panel must be on screen");
}
}
#[test]
fn menu_items_cover_the_tree() {
assert_eq!(menus().last(), Some(&"Help"));