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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
This commit is contained in:
Claude
2026-10-07 22:13:19 +00:00
parent b26f55a775
commit b4d2d58eeb
8 changed files with 208 additions and 18 deletions
+6 -1
View File
@@ -136,7 +136,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
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).
+27 -2
View File
@@ -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<Value> {
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<Value> {
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<Value> {
+31
View File
@@ -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();