Graph Editor: keep close focus and locked selections consistent (#1342)

This commit is contained in:
Kristian Myram committed 2026-10-29 21:58:14 +01:00
1 parent e32eaa03ad
commit b3887c226f
6 files changed
+88 -6

No files matched your search

+12 -3
View File
@@ -55,6 +55,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
}
fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect()
}
/// Paste copied keyframes at the current time into the target layers' properties.
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let clip = s.state.key_clipboard.clone();
@@ -117,7 +121,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
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 mut sel = locked_selection(comp, &st.selected_keys);
let retained = sel.len();
for (lid, uid, ci) in &plan {
let Some(l) = comp.layer_mut(*lid) else { continue };
@@ -210,6 +214,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
})
.collect();
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp(cid).ok_or(EngineError::NoComp)?;
let mut selected = locked_selection(comp, &st.selected_keys);
let retained = selected.len();
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
let mut sel = vec![];
@@ -224,8 +231,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
retime_roving(&mut pr.keys, pr.spatial);
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
Ok(json!(st.selected_keys.len()))
selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
let count = selected.len().saturating_sub(retained);
st.selected_keys = selected;
Ok(json!(count))
})
}
+28
View File
@@ -181,6 +181,34 @@ fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
assert_eq!(s.project.to_json(), pasted);
}
#[test]
fn motion_path_paste_preserves_locked_source_selection() {
for explicit in [false, true] {
let (mut s, source, _) = setup();
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
let locked_keys = s.state.selected_keys.clone();
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = locked_keys.clone();
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 4);
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
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();
+1 -1
View File
@@ -398,7 +398,7 @@ impl EffectcraftApp {
/// A press in a panel (its body, tab or group header) focuses it; another Composition viewer
/// also becomes the active one.
fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
pub(crate) fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
self.ui.focused = p;
panels::viewer_text::panel_clicked(self, ctx, p);
if let Some(id) = panels::viewers::id_of(p) {
+1 -1
View File
@@ -125,7 +125,7 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
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;
return !app.ui.dock.locked;
}
if e.command == "window.panel" {
return true;
+2 -1
View File
@@ -217,12 +217,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" && no_params(&params) && app.ui.focused == PanelKind::RenderQueue {
app.close_panel(PanelKind::RenderQueue);
app.apply_dock(craft_ui::docking::Action::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.
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);
app.focus_panel(ctx, next);
return Ok(Value::Null);
}
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
+44
View File
@@ -473,6 +473,50 @@ fn closing_the_render_queue_reveals_the_panel_receiving_focus() {
}
}
#[test]
fn queue_close_preserves_state_when_workspace_is_locked() {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap();
app.show_panel(PanelKind::RenderQueue);
app.ui.dock.locked = true;
let workspace = app.ui.dock.clone();
let comp = app.session.active_comp_id();
for _ in 0..2 {
assert!(invoke(&mut app, &ctx, "file.close", Value::Null).is_err());
assert_eq!(app.ui.dock, workspace);
assert_eq!(app.ui.focused, PanelKind::RenderQueue);
assert_eq!(app.session.active_comp_id(), comp);
}
assert!(!menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled);
app.ui.dock.locked = false;
invoke(&mut app, &ctx, "file.close", Value::Null).unwrap();
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
}
#[test]
fn queue_close_preserves_viewer_command_target() {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let a = app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let b = app.session.execute("comp.new", json!({"name": "B", "duration": 2})).unwrap()["comp"].as_u64().unwrap();
app.show_panel(PanelKind::Viewer(1));
app.ui.viewers.insert(0, Some(a));
app.ui.viewers.insert(1, Some(b));
app.ui.active_viewer = 1;
app.close_panel(PanelKind::Timeline);
app.show_panel(PanelKind::Composition);
app.show_panel(PanelKind::RenderQueue);
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(b));
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert_eq!(app.ui.focused, PanelKind::Composition);
assert_eq!(app.ui.active_viewer, 0);
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a));
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a));
assert!(app.session.state.open_comps.iter().any(|id| id.0 == b));
}
#[test]
fn menu_items_cover_the_tree() {
assert_eq!(menus().last(), Some(&"Help"));