mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-13 23:17:30 +00:00
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:
8 files changed
+208
-18
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user