From b4d2d58eeb1d4d1dad2210760c2eeaf94e3f28a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH] Masks and Project panel: selecting a mask selects its points; linked Mask Feather; selection box (#203) - Selecting a mask in the Timeline (its row or Mask Path), or a shape's Path, selects all its points, so dragging one in the viewer moves the whole mask, as in After Effects. Delete still removes the mask, and selecting another property deselects the points. - Mask Feather has the chain link (Constrain Proportions, on by default, Alt edits one value) and no longer takes negative values; Scale's link shows as a toggle too. - Dragging in the Project panel's empty area draws a selection box (Shift / Cmd adds); a click there deselects. Tests: selecting_a_mask_selects_its_points, mask_feather_values_are_linked_and_not_negative, dragging_in_the_empty_area_box_selects_items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/edit.rs | 7 ++- crates/engine/src/commands/prop.rs | 29 +++++++++++- crates/engine/src/tests.rs | 31 +++++++++++++ crates/ui-egui/src/panels/project.rs | 42 +++++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 52 ++++++++++++++++++---- crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/tests/ui_project_rows.rs | 24 ++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 36 +++++++++++++-- 8 files changed, 208 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index d60daf49..21e83a0c 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -136,7 +136,12 @@ fn delete(s: &mut Session, p: &Value) -> Result { if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { return s.execute("keys.delete", json!({})); } - if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { + // (Points selected with their mask, which selecting a mask does: the mask goes, below.) + let comp = s.active_comp(); + let loose = s.state.selected_vertices.iter().any(|v| { + !s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some()) + }); + if loose && p.get("layers").is_none() { return s.execute("mask.deleteVertices", json!({})); } // Puppet pins selected → delete the pins (not their layer). diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 409210e8..6e82fc2a 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -1,12 +1,12 @@ //! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures). use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key}; -use effectcraft_project::{ItemId, LayerId, Property, Uid}; +use effectcraft_project::{GroupKind, ItemId, Layer, 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, layers_p, merge_p, str_p}; -use crate::{EngineError, KeyRef, Result, Session, cmd, query}; +use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { @@ -293,11 +293,19 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { let add = b_p(p, "add").unwrap_or(false); if !add { s.state.selected_props.clear(); + s.state.selected_vertices.clear(); } if !s.state.selected_layers.contains(&lid) { s.state.selected_layers = vec![lid]; } s.state.selected_props.push((lid, uid)); + // Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the + // viewer drags the whole mask (as in AE, #203). + let t = s.time(); + if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) { + s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask)); + s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index })); + } // Selecting a property selects all its keys (as in AE). if b_p(p, "selectKeys").unwrap_or(true) && let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)) @@ -311,6 +319,23 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number +/// of points at layer time `lt`. +fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> { + let owner = match l.props.find_group(uid) { + Some(g) => g, + None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?, + }; + let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some()); + if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents { + return None; + } + match owner.get("path")?.value_at(lt) { + effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())), + _ => None, + } +} + // ---------------------------------------------------------------- keyframes fn select_keys(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8ae9fcd4..ef467b93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -217,6 +217,37 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() { let _ = s.render(cid, s.time(), Default::default()); } +/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the +/// viewer drags the whole mask (#203); a shape's Path item works the same way. +#[test] +fn selecting_a_mask_selects_its_points() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let m1 = s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap()["mask"].as_u64().unwrap(); + let m2 = s.execute("layer.addMask", json!({"layer": l, "shape": "ellipse"})).unwrap()["mask"].as_u64().unwrap(); + let points = |s: &Session, m: u64| s.state.selected_vertices.iter().filter(|v| v.mask == m).count(); + s.execute("prop.select", json!({"layer": l, "prop": m1, "selectKeys": false})).unwrap(); + assert_eq!((points(&s, m1), s.state.selected_vertices.len()), (4, 4)); + // Mask Path selects them too; Shift-adding another mask keeps the first. + let path = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().props.find_group(m2).unwrap().get("path").unwrap().uid; + s.execute("prop.select", json!({"layer": l, "prop": path, "add": true})).unwrap(); + assert_eq!((points(&s, m1), points(&s, m2)), (4, 4)); + // Another mask replaces them; another property deselects them. + s.execute("prop.select", json!({"layer": l, "prop": m2})).unwrap(); + assert_eq!((points(&s, m1), points(&s, m2)), (0, 4)); + // Delete removes the selected mask (not only its points). + s.execute("edit.clear", json!({})).unwrap(); + let masks = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().masks().unwrap().children.len(); + assert_eq!(masks(&s), 1); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert!(s.state.selected_vertices.is_empty()); + let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap(); + let star = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "path"})).unwrap()["uid"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": sh, "prop": star})).unwrap(); + assert_eq!(s.state.selected_vertices.len(), 0, "an empty Path has no points"); +} + #[test] fn save_and_open_roundtrip() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 2f9963d9..8d84e71d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -323,9 +323,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); // The list's empty area (under the rows and the scroll bar, which take their own clicks): - // a double-click imports, as in After Effects (File ▸ Import ▸ File...). - let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click()); - app.auto.add("project.empty", list, "Double-click to import files"); + // a click deselects, a drag draws a selection box, a double-click imports, as in After + // Effects (File ▸ Import ▸ File...). + let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag()); + app.auto.add("project.empty", list, "Double-click to import files; drag to select items"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); let d = if dx.abs() > 0.0 { @@ -718,6 +719,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if empty.double_clicked() { actions.push(("file.import".into(), json!({}))); } + // Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection. + let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if empty.clicked() && !adding { + app.session.state.project_selection.clear(); + } + let box_id = egui::Id::new("proj-marquee"); + if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), empty.interact_pointer_pos()) + && empty.dragged() + { + let b = Rect::from_two_pos(start, end).intersect(list); + lp.rect_filled(b, 0.0, t.accent.gamma_multiply(0.12)); + lp.rect_stroke(b, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ctx.data_mut(|d| d.insert_temp(box_id, b)); + } + if empty.drag_stopped() + && let Some(b) = ctx.data(|d| d.get_temp::(box_id)) + { + ctx.data_mut(|d| d.remove::(box_id)); + // Rows sit at fixed heights from the top of the scrolled list. + let top = list.min.y - app.ui.project_scroll; + let picked = rows.iter().enumerate().filter(|(i, _)| { + let y = top + *i as f32 * ROW_H; + y < b.max.y && y + ROW_H > b.min.y + }); + let picked: Vec = picked.map(|(_, (id, _))| *id).collect(); + let sel = &mut app.session.state.project_selection; + if !adding { + sel.clear(); + } + for id in picked { + if !sel.contains(&id) { + sel.push(id); + } + } + } for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8c2bbf0d..233e1384 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2882,6 +2882,26 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} + /// Inline value editor for a property row; pushes `prop.set` actions. fn value_editor( app: &mut EffectcraftApp, @@ -2929,21 +2949,35 @@ fn value_editor( let c = value.components(); let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() }; let pct = matches!(prop.ui, ParamUi::Percent); + // Mask Feather: two linked values, never negative (as in After Effects, #203). + let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); + let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; + // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). + let linkable = feather || (pct && prop.match_id == "scale"); + let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + if linkable { + let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); + let resp = ui.interact(lr, egui::Id::new(("tl-link", uid)), Sense::click()).on_hover_text("Constrain Proportions"); + icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); + app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); + if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { + app.ui.timeline.unlinked.insert(uid); + } + x += 18.0; + } for d in 0..n.min(c.len()) { let suffix = if pct && d + 1 == n { "%" } else { "" }; - let (r, nv, _) = - widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, (-1e9, 1e9), 1, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); if let Some(nv) = nv { let mut nc = c.clone(); - if pct && prop.match_id == "scale" && !ui.input(|i| i.modifiers.alt) { - // Constrain proportions (AE's chain link, on by default). - let k = if c[d].abs() > 1e-9 { nv / c[d] } else { 1.0 }; - for e in 0..n { - nc[e] = if e == d { nv } else { c[e] * k }; + // Alt edits one value of a linked pair. + let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; + let from = if shown == 1 { d } else { 0 }; + for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + if let Some(slot) = nc.get_mut(from + e) { + *slot = v; } - } else { - nc[d] = nv; } set(actions, json!(nc)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 041ce593..398d724f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -413,6 +413,10 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default) + /// was turned off. + #[serde(default)] + pub unlinked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -458,6 +462,7 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, } diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index cd15d46e..074b41b3 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -95,3 +95,27 @@ fn label_swatch_opens_the_label_menu() { assert_eq!(h.state().session.history.undo.len(), undo + 1); assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice"); } + +/// Dragging from the Project panel's empty area draws a selection box that selects the rows it +/// touches; a click there deselects (#203). +#[test] +fn dragging_in_the_empty_area_box_selects_items() { + let (mut h, folder, _, solid) = harness(); + let list = h.state().auto.find("project.empty").unwrap().rect; + let from = pos2(list[0] + 40.0, list[1] + list[3] - 6.0); + let to = center(&h, &format!("project.item.{}.name", folder.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + button(&mut h, from, true); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + button(&mut h, to, false); + h.run_steps(2); + let sel = h.state().session.state.project_selection.clone(); + assert!(sel.contains(&folder) && sel.contains(&solid), "{sel:?}"); + click_at(&mut h, from); + assert!(h.state().session.state.project_selection.is_empty()); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 246651bb..27389247 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,6 +1,6 @@ -//! Timeline rows for shape layers and keyframing: the Contents "Add:" menu and the Classic 3D -//! "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds, clicks below the layers -//! deselecting them and the toolbar's Stroke on a selected shape layer (#205). +//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic +//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the +//! layers deselecting them (#205), and Mask Feather's linked values (#203). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -106,6 +106,36 @@ fn clicking_below_the_layers_deselects_them() { assert!(h.state().session.state.selected_layers.is_empty()); } +#[test] +fn mask_feather_values_are_linked_and_not_negative() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap(); + let feather = |h: &Harness<'_, EffectcraftApp>| { + let p = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("masks/#1/feather").unwrap().clone(); + (p.uid, p.value.components()) + }; + let mut h = harness(s); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["feather".into()]); + h.run_steps(2); + let (uid, _) = feather(&h); + let type_value = |h: &mut Harness<'_, EffectcraftApp>, d: usize, v: &str| { + click(h, &format!("timeline.prop.{uid}.value.{d}")); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + type_value(&mut h, 0, "12"); + assert_eq!(feather(&h).1, vec![12.0, 12.0], "linked by default"); + type_value(&mut h, 1, "-5"); + assert_eq!(feather(&h).1, vec![0.0, 0.0], "never negative"); + click(&mut h, &format!("timeline.prop.{uid}.link")); + type_value(&mut h, 1, "7"); + assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); +} + #[test] fn toolbar_stroke_edits_the_selected_shape_layer() { let mut s = session();