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 committed 2026-10-07 22:13:19 +00:00
1 parent b26f55a775
commit b4d2d58eeb
8 files changed
+208 -18

No files matched your search

+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();
+39 -3
View File
@@ -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::<Rect>(box_id))
{
ctx.data_mut(|d| d.remove::<Rect>(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<ItemId> = 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;
+43 -9
View File
@@ -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<f64> {
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));
}
+5
View File
@@ -413,6 +413,10 @@ pub struct TimelineState {
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
/// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default)
/// was turned off.
#[serde(default)]
pub unlinked: BTreeSet<u64>,
/// 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,
}
+24
View File
@@ -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());
}
+33 -3
View File
@@ -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();