mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 14:08:38 -06:00
Graph Editor: close remaining lock and menu gaps (#1342)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(¶ms) && 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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user