Merge pull request #221 from storytold/fix/203-205-206-timeline-shapes
Timeline, shapes and masks: Add menu, Change Renderer, toolbar Fill / Stroke, Alt+Shift+P reveal, mask selection, linked feather, Project selection box (#203, #205, #206)
This commit is contained in:
commit
ff163d42a0
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1075,7 +1075,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
|
||||
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
|
||||
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
|
||||
"path" => build::shape_path(&mut ids, Default::default()),
|
||||
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
|
||||
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
|
||||
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
|
||||
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
|
||||
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
|
||||
|
|
@ -1404,7 +1406,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub mod roto_cmds;
|
|||
mod scene_detect;
|
||||
pub mod scripts;
|
||||
mod settings;
|
||||
mod shape_stroke;
|
||||
pub mod shape_stroke;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
|
||||
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
|
||||
//! commands here set several of them in one undo step and add the groups to strokes saved before
|
||||
//! they existed.
|
||||
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
|
||||
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
|
||||
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
|
||||
use super::layer::color_p;
|
||||
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn is_stroke(g: &PropGroup) -> bool {
|
||||
|
|
@ -195,8 +197,116 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Path items a Fill or Stroke paints in their group.
|
||||
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
|
||||
|
||||
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
|
||||
/// `lt` (what the toolbar shows for a selected shape layer).
|
||||
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
|
||||
let (mut fill, mut stroke, mut width) = (None, None, None);
|
||||
if let Some(c) = layer.props.sub("contents") {
|
||||
walk_groups(c, &mut |g| {
|
||||
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
|
||||
let rgba = |v: KV| v.as_color().map(f64::from);
|
||||
match g.match_id.as_str() {
|
||||
"fill" if fill.is_none() => fill = at("color").map(rgba),
|
||||
"stroke" if stroke.is_none() => {
|
||||
stroke = at("color").map(rgba);
|
||||
width = at("width").map(|v| v.as_f64());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
|
||||
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
|
||||
/// its fills, as new shapes have it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
|
||||
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
|
||||
}
|
||||
for n in &mut c.children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
let set = |g: &mut PropGroup, id: &str, v: Option<KV>| {
|
||||
if let (Some(v), Some(p)) = (v, g.get_mut(id)) {
|
||||
p.set_value_at(lt, v);
|
||||
}
|
||||
};
|
||||
match g.match_id.as_str() {
|
||||
"fill" => set(g, "color", fill.map(KV::Color)),
|
||||
"stroke" => {
|
||||
set(g, "color", stroke.map(KV::Color));
|
||||
set(g, "width", width.map(KV::Scalar));
|
||||
}
|
||||
"gstroke" => set(g, "width", width.map(KV::Scalar)),
|
||||
"group" => {
|
||||
if let Some(sub) = g.sub_mut("contents") {
|
||||
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
|
||||
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
|
||||
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
|
||||
/// have no stroke one. Without values, reports the first layer's paint.
|
||||
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let shapes: Vec<LayerId> = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect();
|
||||
if shapes.is_empty() {
|
||||
return Err(bad(c, "select a shape layer"));
|
||||
}
|
||||
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
|
||||
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
|
||||
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() {
|
||||
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
|
||||
let t = s.time();
|
||||
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(contents) = l.props.sub_mut("contents") {
|
||||
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
|
||||
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"shape.fillStroke",
|
||||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
|
||||
has_layers,
|
||||
fill_stroke
|
||||
),
|
||||
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
|
||||
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
|
||||
cmd!(
|
||||
|
|
|
|||
|
|
@ -183,6 +183,71 @@ fn text_shape_mask_matte_parent() {
|
|||
assert_eq!(info["layers"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn
|
||||
/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include
|
||||
/// an empty Path and a Gradient Stroke that render.
|
||||
#[test]
|
||||
fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap();
|
||||
assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke");
|
||||
s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap();
|
||||
let p = paint(&mut s);
|
||||
assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}");
|
||||
s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap();
|
||||
let p = paint(&mut s);
|
||||
assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}");
|
||||
// One stroke, before the fill (paths, stroke, fill), and each change is one undo step.
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
|
||||
let group = l.props.group("contents/group").unwrap();
|
||||
let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
|
||||
assert_eq!(order, ["rect", "stroke", "fill"]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0));
|
||||
// Only shape layers.
|
||||
s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap();
|
||||
assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err());
|
||||
for kind in ["path", "gstroke"] {
|
||||
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap();
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}");
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::state::Tool;
|
|||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
use effectcraft_engine::project::{LayerId, LayerSource};
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
|
|
@ -91,7 +92,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
|
||||
// Tool options.
|
||||
x += 10.0;
|
||||
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen {
|
||||
// Selected shape layers: the options show their paint and edit it (as in After Effects).
|
||||
let shape_layers: Vec<u64> = app
|
||||
.session
|
||||
.active_comp()
|
||||
.map(|c| {
|
||||
app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(l) = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) {
|
||||
let (fill, stroke, width) = effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time()));
|
||||
let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32];
|
||||
if let Some(c) = fill {
|
||||
app.ui.fill_color = rgb(c);
|
||||
}
|
||||
if let Some(c) = stroke {
|
||||
app.ui.stroke_color = rgb(c);
|
||||
}
|
||||
app.ui.stroke_width = width.unwrap_or(0.0) as f32;
|
||||
}
|
||||
// One undo step per picker session or width drag: end the merge once both are done.
|
||||
let picking = ["tool-fill-pop", "tool-stroke-pop"].iter().any(|k| ui.data(|d| d.get_temp::<bool>(egui::Id::new(*k).with("open")).unwrap_or(false)));
|
||||
if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) {
|
||||
app.session.history.merge_key = None;
|
||||
}
|
||||
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen || !shape_layers.is_empty() {
|
||||
let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| {
|
||||
if shape_layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let merge = format!("tool-{key}");
|
||||
if let Err(e) = app.session.execute("shape.fillStroke", serde_json::json!({"layers": shape_layers, key: v, "merge": merge})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
};
|
||||
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Fill:", Tokens::ui(12.0), t.text_dim);
|
||||
x += 28.0;
|
||||
let fr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0));
|
||||
|
|
@ -99,7 +133,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if widgets::swatch(ui, fr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-fill"), &t).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("tool-fill-pop"));
|
||||
}
|
||||
color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color);
|
||||
if color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color) {
|
||||
let c = app.ui.fill_color;
|
||||
paint(app, "fill", serde_json::json!(c));
|
||||
}
|
||||
app.auto.add("header.fill", fr, "Fill Color");
|
||||
x += 32.0;
|
||||
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Stroke:", Tokens::ui(12.0), t.text_dim);
|
||||
x += 44.0;
|
||||
|
|
@ -108,13 +146,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if widgets::swatch(ui, sr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-stroke"), &t).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("tool-stroke-pop"));
|
||||
}
|
||||
color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color);
|
||||
if color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color) {
|
||||
let c = app.ui.stroke_color;
|
||||
paint(app, "stroke", serde_json::json!(c));
|
||||
}
|
||||
app.auto.add("header.stroke", sr, "Stroke Color");
|
||||
x += 26.0;
|
||||
let (r, v, _) =
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new("tool-stroke-w"), app.ui.stroke_width as f64, 0.2, (0.0, 1000.0), 0, " px", &t);
|
||||
if let Some(v) = v {
|
||||
app.ui.stroke_width = v as f32;
|
||||
paint(app, "strokeWidth", serde_json::json!(v));
|
||||
}
|
||||
app.auto.add("header.strokeWidth", r, "Stroke Width");
|
||||
x = r.max.x + 14.0;
|
||||
}
|
||||
if app.ui.tool.puppet_kind().is_some() {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,35 @@ fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) {
|
|||
}
|
||||
}
|
||||
|
||||
/// After Alt+Shift+P (A, S, R, T) the Timeline shows the property on each layer it keyed, as in
|
||||
/// After Effects: added to what a reveal shortcut shows, else with the layer's Transform twirled
|
||||
/// open, else alone like P.
|
||||
fn reveal_keyed(app: &mut EffectcraftApp, keyed: &Value, kind: &str) {
|
||||
let layers: std::collections::BTreeSet<u64> = keyed.as_array().into_iter().flatten().filter_map(|k| k.get("layer").and_then(Value::as_u64)).collect();
|
||||
let Some(comp) = app.session.active_comp() else { return };
|
||||
let tl = &mut app.ui.timeline;
|
||||
for id in layers {
|
||||
let shown = tl.layer_reveal.get(&id).filter(|k| !k.is_empty()).cloned();
|
||||
match shown {
|
||||
Some(mut kinds) => {
|
||||
if !kinds.iter().any(|k| k == kind) {
|
||||
kinds.push(kind.to_string());
|
||||
tl.layer_reveal.insert(id, kinds);
|
||||
}
|
||||
}
|
||||
None if tl.open_layers.contains(&id) => {
|
||||
if let Some(tr) = comp.layer(effectcraft_engine::project::LayerId(id)).and_then(|l| l.transform()) {
|
||||
tl.open_groups.insert(tr.uid);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tl.open_layers.insert(id);
|
||||
tl.layer_reveal.insert(id, vec![kind.to_string()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp.
|
||||
fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
|
||||
if app.session.state.selected_layers.is_empty() {
|
||||
|
|
@ -314,7 +343,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
return Ok(Value::Null);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.keyAt.") {
|
||||
return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}));
|
||||
let r = run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}))?;
|
||||
reveal_keyed(app, &r, k);
|
||||
return Ok(r);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.revealAdd.") {
|
||||
reveal(app, k, now, true);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ enum RowKind {
|
|||
/// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid).
|
||||
const WAVE_BIT: u64 = 1 << 60;
|
||||
|
||||
/// Synthetic group uid for the Geometry Options row of a 3D text or shape layer in a Classic 3D
|
||||
/// comp, which only offers "Change Renderer…" (never a real property uid).
|
||||
const GEOM_BIT: u64 = 1 << 59;
|
||||
|
||||
/// Classic 3D comps don't extrude: a 3D text or shape layer's Geometry Options row offers to
|
||||
/// change the renderer instead, as in After Effects.
|
||||
fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool {
|
||||
comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape)
|
||||
}
|
||||
|
||||
/// Time navigator's visible-span bar and the work area bar.
|
||||
const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55);
|
||||
const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c);
|
||||
|
|
@ -597,8 +607,11 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
|||
reveal_rows(app, l, kinds, &mut rows);
|
||||
continue;
|
||||
}
|
||||
let classic_geometry = classic_geometry_row(comp, l);
|
||||
for c in &l.props.children {
|
||||
match c {
|
||||
// The Change Renderer row (after Transform) stands in for the extrusion options.
|
||||
Node::Group(g) if classic_geometry && g.match_id == "geometryOptions" => {}
|
||||
Node::Group(g) if group_visible(g, l) => {
|
||||
let open = tl.open_groups.contains(&g.uid);
|
||||
rows.push(Row {
|
||||
|
|
@ -631,6 +644,20 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
|||
}
|
||||
}
|
||||
}
|
||||
if classic_geometry && g.match_id == "transform" {
|
||||
rows.push(Row {
|
||||
layer: l.id,
|
||||
depth: 1,
|
||||
kind: RowKind::Group {
|
||||
uid: GEOM_BIT | l.id.0,
|
||||
name: "Geometry Options".into(),
|
||||
open: false,
|
||||
has_children: false,
|
||||
fx: None,
|
||||
eye: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
|
||||
_ => {}
|
||||
|
|
@ -1704,6 +1731,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
|
||||
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
|
||||
shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
|
||||
if *uid == GEOM_BIT | layer.id.0 {
|
||||
let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, "Change Renderer…", Tokens::ui(11.5), t.accent);
|
||||
let resp =
|
||||
ui.interact(lr, egui::Id::new(("tl-change-renderer", layer.id.0)), Sense::click()).on_hover_text("Composition Settings ▸ 3D Renderer");
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
|
||||
if resp.clicked() {
|
||||
ui_actions.push(UiAct::ChangeRenderer);
|
||||
}
|
||||
}
|
||||
dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
|
||||
// Mask mode + inverted inline.
|
||||
if let Some(g) = layer.props.find_group(*uid)
|
||||
|
|
@ -1727,7 +1767,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
|
||||
let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click());
|
||||
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
|
||||
if gr.clicked() && uid & WAVE_BIT == 0 {
|
||||
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||||
}
|
||||
if gr.double_clicked() {
|
||||
|
|
@ -2175,9 +2215,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if graph_on {
|
||||
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
|
||||
}
|
||||
// Empty-area click in the graph: deselect keys; drag: box-select keys.
|
||||
// Empty-area click in the graph: deselect keys (below the last row, the layers too, as in
|
||||
// After Effects); drag: box-select keys.
|
||||
if empty.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y));
|
||||
if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
|
||||
actions.push(("layer.select".into(), json!({"layers": []})));
|
||||
}
|
||||
}
|
||||
if let (true, Some(origin), Some(cur)) =
|
||||
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
|
||||
|
|
@ -2444,6 +2489,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
UiAct::SetTime(tt) => app.session.set_time(tt),
|
||||
UiAct::EndMerge => app.session.history.merge_key = None,
|
||||
UiAct::ChangeRenderer => {
|
||||
if super::dialogs::open_comp_settings(app).is_ok() {
|
||||
app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
|
||||
|
|
@ -2561,6 +2611,8 @@ enum UiAct {
|
|||
ToggleGroup(u64),
|
||||
SetTime(Tick),
|
||||
EndMerge,
|
||||
/// Composition Settings on its 3D Renderer tab.
|
||||
ChangeRenderer,
|
||||
}
|
||||
|
||||
fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
|
||||
|
|
@ -2601,6 +2653,75 @@ fn dash_buttons(
|
|||
}
|
||||
}
|
||||
|
||||
/// The shape items the Contents row's "Add:" menu offers, as `layer.addShapeItem` kinds ("-" is
|
||||
/// a separator), in After Effects' order.
|
||||
const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
|
||||
("group", "Group (empty)"),
|
||||
("-", ""),
|
||||
("rect", "Rectangle"),
|
||||
("ellipse", "Ellipse"),
|
||||
("star", "Polystar"),
|
||||
("path", "Path"),
|
||||
("-", ""),
|
||||
("fill", "Fill"),
|
||||
("stroke", "Stroke"),
|
||||
("gfill", "Gradient Fill"),
|
||||
("gstroke", "Gradient Stroke"),
|
||||
("-", ""),
|
||||
("merge", "Merge Paths"),
|
||||
("offset", "Offset Paths"),
|
||||
("pucker", "Pucker & Bloat"),
|
||||
("repeater", "Repeater"),
|
||||
("round", "Round Corners"),
|
||||
("trim", "Trim Paths"),
|
||||
("twist", "Twist"),
|
||||
("wiggle", "Wiggle Paths"),
|
||||
("zigzag", "Zig Zag"),
|
||||
];
|
||||
|
||||
/// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
|
||||
/// selected shape group, else to the layer's contents.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_add_popup(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
uid: u64,
|
||||
x: f32,
|
||||
cy: f32,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, "Add:", Tokens::ui(11.5), t.text_dim);
|
||||
let br = Rect::from_center_size(pos2(x + 40.0, cy), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(br, egui::Id::new(("tl-shapeadd", uid)), Sense::click()).on_hover_text("Add a shape, fill, stroke or path operation");
|
||||
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.group.{uid}.add"), br, "Add:");
|
||||
let pop = egui::Id::new(("tl-shapeadd-pop", uid));
|
||||
if resp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
let opts: Vec<String> = SHAPE_ADD_ITEMS.iter().map(|(k, l)| if *k == "-" { "-".to_string() } else { l.to_string() }).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None)
|
||||
&& let Some((kind, _)) = SHAPE_ADD_ITEMS.get(i).filter(|(k, _)| *k != "-")
|
||||
{
|
||||
let mut params = json!({"layer": layer.id.0, "kind": kind});
|
||||
// Into the selected shape group, as After Effects does.
|
||||
let group = app.session.state.selected_props.iter().filter(|(l, _)| *l == layer.id).find_map(|(_, u)| {
|
||||
let g = layer.props.find_group(*u)?;
|
||||
(g.match_id == "group" && g.sub("contents").is_some()).then_some(g.uid)
|
||||
});
|
||||
if let Some(g) = group {
|
||||
params["group"] = json!(g);
|
||||
}
|
||||
actions.push(("layer.addShapeItem".into(), params));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_anim_popups(
|
||||
|
|
@ -2761,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,
|
||||
|
|
@ -2808,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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,10 +283,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
|
|||
if !open {
|
||||
return None;
|
||||
}
|
||||
// As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond,
|
||||
// and moved up when it would run past the bottom of the window (the Timeline's menus).
|
||||
let screen = ui.ctx().content_rect();
|
||||
let max_h = (screen.height() - 24.0).max(120.0);
|
||||
let sp = ui.spacing();
|
||||
let seps = options.iter().filter(|o| *o == "-").count() as f32;
|
||||
let rows = options.len() as f32 - seps;
|
||||
let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0);
|
||||
let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top()));
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(160.0);
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
// (An area's content is laid out in last frame's size: ask for the estimate.)
|
||||
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| {
|
||||
for (i, o) in options.iter().enumerate() {
|
||||
if o == "-" {
|
||||
ui.separator();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
//! 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;
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
}
|
||||
|
||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = center(h, id);
|
||||
click_at(h, p);
|
||||
}
|
||||
|
||||
fn open_layer(h: &mut Harness<'_, EffectcraftApp>, id: u64) {
|
||||
h.state_mut().ui.timeline.open_layers.insert(id);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_contents_add_menu_adds_path_operations() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid;
|
||||
let mut h = harness(s);
|
||||
open_layer(&mut h, l);
|
||||
click(&mut h, &format!("timeline.group.{contents}.add"));
|
||||
let entry = h.get_by_label("Trim Paths").rect();
|
||||
assert!(entry.max.y <= 1000.0, "the whole menu fits in the window: {entry:?}");
|
||||
click_at(&mut h, entry.center());
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
|
||||
assert_eq!(items, ["group", "trim"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_3d_shape_layers_offer_change_renderer() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
let mut h = harness(s);
|
||||
open_layer(&mut h, l);
|
||||
click(&mut h, &format!("timeline.layer.{l}.changeRenderer"));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
|
||||
assert!(h.state().auto.find("dialog.comp.renderer").is_some(), "on the 3D Renderer tab");
|
||||
// Advanced 3D shows the extrusion options instead.
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().session.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_position_key_reveals_position() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.keyAt.position", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
let position = app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone();
|
||||
assert_eq!(position.keys.len(), 1);
|
||||
assert_eq!(app.ui.timeline.layer_reveal.get(&l), Some(&vec!["position".to_string()]));
|
||||
assert!(app.auto.find(&format!("timeline.prop.{}.value.0", position.uid)).is_some(), "the Position row shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_below_the_layers_deselects_them() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(l)]);
|
||||
// In the time graph, below the only layer.
|
||||
let bar = center(&h, &format!("timeline.layer.{l}.bar"));
|
||||
click_at(&mut h, bar + egui::vec2(0.0, 80.0));
|
||||
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();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
// The Selection tool is active: the options show for the selected shape layer.
|
||||
click(&mut h, "header.strokeWidth");
|
||||
h.input_mut().events.push(Event::Text("4".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);
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64());
|
||||
assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width");
|
||||
assert_eq!(h.state().ui.stroke_width, 4.0);
|
||||
}
|
||||
Loading…
Reference in New Issue