diff --git a/crates/engine/src/commands/anim_tools.rs b/crates/engine/src/commands/anim_tools.rs new file mode 100644 index 00000000..06e0d341 --- /dev/null +++ b/crates/engine/src/commands/anim_tools.rs @@ -0,0 +1,411 @@ +//! Keyframe assistants of the Wiggler, Smoother and Motion Sketch panels (Window ▸ Wiggler / +//! Smoother / Motion Sketch). Each is one undoable command, so agents get the same results as the +//! panels: +//! +//! * `keys.wiggle` adds random deviation keys between the first and last selected keys of each +//! property (Spatial or Temporal path, smooth or jagged noise, one / all-the-same / independent +//! dimensions, frequency in keys per second, magnitude in the property's units). Seeded, so a +//! given selection and seed always produce the same keys. +//! * `keys.smooth` replaces the selected run of keys by the fewest auto-Bezier keys that stay +//! within `tolerance` of the original curve (sampled every frame). +//! * `motion.sketch` turns a recorded pointer path (comp pixels, with capture timestamps) into +//! Position keys, one per frame, then smooths them like the Smoother. + +use effectcraft_keyframe::{Interp, Keyframe, Value as KValue, evaluate}; +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, f_p, has_comp, has_keys, layer_mut, layer_p, str_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +// ---------------------------------------------------------------- noise + +fn splitmix(mut x: u64) -> u64 { + x = x.wrapping_add(0x9e37_79b9_7f4a_7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +/// Deterministic uniform noise in [-1, 1] for (seed, stream, index). +fn rnd(seed: u64, stream: u64, i: i64) -> f64 { + let h = splitmix(seed ^ splitmix(stream.wrapping_mul(0x1000_0000_01b3) ^ (i as u64))); + (h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 +} + +/// Wiggler noise type. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Noise { + /// Gradual deviations: lattice noise at half the key rate with cosine interpolation. + Smooth, + /// Independent deviations per key. + Jagged, +} + +/// Noise sample `k` (0-based key index) in [-1, 1]. +fn noise(kind: Noise, seed: u64, stream: u64, k: i64) -> f64 { + match kind { + Noise::Jagged => rnd(seed, stream, k), + Noise::Smooth => { + let (i, f) = (k.div_euclid(2), (k.rem_euclid(2)) as f64 / 2.0); + let w = (1.0 - (f * std::f64::consts::PI).cos()) / 2.0; + rnd(seed, stream, i) * (1.0 - w) + rnd(seed, stream, i + 1) * w + } + } +} + +/// Which dimensions the Wiggler affects. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dims { + /// Only dimension `n`. + One(usize), + /// All dimensions by the same amount. + Same, + /// Each dimension independently. + Independent, +} + +/// Wiggler settings. +#[derive(Clone, Copy, Debug)] +pub struct WiggleOpts { + /// Spatial path (deviate values along the motion path) or temporal path (deviate timing on + /// spatial properties; values on others). + pub spatial: bool, + pub noise: Noise, + pub dims: Dims, + /// Keys per second. + pub frequency: f64, + pub magnitude: f64, + pub seed: u64, +} + +/// Wiggle `keys` between keys `a` and `b` (indices, `a < b`): returns the new key list. New keys +/// fall on whole frames of `fr` (via `to_layer`/`to_comp` time maps). `spatial_prop` selects +/// motion-path semantics. +#[allow(clippy::too_many_arguments)] +pub fn wiggle_keys( + keys: &[Keyframe], + a: usize, + b: usize, + spatial_prop: bool, + fr: FrameRate, + to_comp: &dyn Fn(Tick) -> Tick, + to_layer: &dyn Fn(Tick) -> Tick, + o: &WiggleOpts, + stream: u64, +) -> Vec { + let (t0, t1) = (keys[a].time, keys[b].time); + let (f0, f1) = (fr.frame_at(to_comp(t0)), fr.frame_at(to_comp(t1))); + let fps = fr.as_f64(); + let step = ((fps / o.frequency.max(0.01)).round() as i64).max(1); + let orig = keys.to_vec(); + let mut out: Vec = keys.iter().filter(|k| k.time <= t0 || k.time >= t1).cloned().collect(); + let template = keys[a].clone(); + let dims = template.value.components().len(); + let mut k = 1; + loop { + let f = f0 + k * step; + if f >= f1 { + break; + } + let lt = to_layer(fr.tick_of(f)); + let base_t = if o.spatial || !spatial_prop { + lt + } else { + // Temporal path on a spatial property: sample the path at a jittered time (magnitude in + // percent of the key spacing), so the speed along the path wiggles. + let jit = noise(o.noise, o.seed, stream, k) * o.magnitude / 100.0 * step as f64 / fps; + Tick::from_seconds_f64((lt.seconds() + jit).clamp(t0.seconds(), t1.seconds())) + }; + let Some(v) = evaluate(&orig, base_t, spatial_prop) else { break }; + let mut c = v.components(); + if o.spatial || !spatial_prop { + for (d, x) in c.iter_mut().enumerate().take(dims) { + let n = match o.dims { + Dims::One(i) if i != d => 0.0, + Dims::One(_) | Dims::Same => noise(o.noise, o.seed, stream, k), + Dims::Independent => noise(o.noise, o.seed, stream.wrapping_add(d as u64 * 7919 + 1), k), + }; + *x += n * o.magnitude; + } + } + let mut key = Keyframe::new(lt, v.with_components(&c)); + if o.noise == Noise::Smooth { + key.in_interp = Interp::Bezier; + key.out_interp = Interp::Bezier; + key.auto_bezier = true; + } + out.push(key); + k += 1; + } + out.sort_by_key(|k| k.time); + out +} + +/// Largest distance between the curve of `keys` and `samples`. +fn max_error(keys: &[Keyframe], samples: &[(Tick, Vec)], spatial: bool) -> (f64, usize) { + let mut worst = (0.0, 0); + for (i, (t, v)) in samples.iter().enumerate() { + let Some(e) = evaluate(keys, *t, spatial) else { continue }; + let d = e.components().iter().zip(v).map(|(a, b)| (a - b) * (a - b)).sum::().sqrt(); + if d > worst.0 { + worst = (d, i); + } + } + worst +} + +/// Smoother: replace the keys strictly between `a` and `b` (indices) by the fewest auto-Bezier +/// keys at sample times that keep the curve within `tolerance` of `samples` (the original +/// curve, usually one sample per frame). Keys outside `a..=b` are kept. +pub fn smooth_keys(keys: &[Keyframe], a: usize, b: usize, samples: &[(Tick, Vec)], tolerance: f64, spatial: bool) -> Vec { + let template = keys[a].value.clone(); + let make = |t: Tick, c: &[f64]| { + let mut k = Keyframe::new(t, template.with_components(c)); + if !spatial { + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + k.auto_bezier = true; + } + k + }; + let mut chosen: Vec = vec![]; + let build = |chosen: &[usize]| { + let mut v: Vec = keys[..=a].to_vec(); + v.extend(chosen.iter().map(|&i| make(samples[i].0, &samples[i].1))); + v.extend(keys[b..].iter().cloned()); + v.sort_by_key(|k| k.time); + v + }; + loop { + let cur = build(&chosen); + let (err, at) = max_error(&cur, samples, spatial); + let t = samples[at].0; + if err <= tolerance || chosen.contains(&at) || t <= keys[a].time || t >= keys[b].time || chosen.len() >= samples.len() { + return cur; + } + chosen.push(at); + chosen.sort_unstable(); + } +} + +/// The curve of `keys` sampled at every comp frame between key times `t0` and `t1` (layer time). +fn sample_curve( + keys: &[Keyframe], + t0: Tick, + t1: Tick, + spatial: bool, + fr: FrameRate, + to_comp: &dyn Fn(Tick) -> Tick, + to_layer: &dyn Fn(Tick) -> Tick, +) -> Vec<(Tick, Vec)> { + let (f0, f1) = (fr.frame_at(to_comp(t0)), fr.frame_at(to_comp(t1))); + (f0..=f1) + .filter_map(|f| { + let t = to_layer(fr.tick_of(f)); + (t >= t0 && t <= t1).then(|| evaluate(keys, t, spatial).map(|v| (t, v.components())))? + }) + .collect() +} + +// ---------------------------------------------------------------- commands + +fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec> { + let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &s.state.selected_keys { + g.entry((k.layer, k.prop)).or_default().push(k.time); + } + for v in g.values_mut() { + v.sort(); + } + g +} + +fn parse_wiggle(p: &Value) -> Result { + let c = "keys.wiggle"; + let spatial = match str_p(p, "apply").unwrap_or("spatial") { + "spatial" | "Spatial Path" => true, + "temporal" | "Temporal Path" => false, + x => return Err(bad(c, format!("apply: spatial|temporal, got `{x}`"))), + }; + let noise = match str_p(p, "noise").unwrap_or("smooth") { + "smooth" | "Smooth" => Noise::Smooth, + "jagged" | "Jagged" => Noise::Jagged, + x => return Err(bad(c, format!("noise: smooth|jagged, got `{x}`"))), + }; + let dims = match str_p(p, "dimensions").unwrap_or("independent") { + "one" => Dims::One(p.get("dimension").and_then(Value::as_u64).unwrap_or(0) as usize), + "same" | "all" => Dims::Same, + "independent" => Dims::Independent, + x => return Err(bad(c, format!("dimensions: one|same|independent, got `{x}`"))), + }; + let frequency = f_p(p, "frequency").unwrap_or(5.0); + if frequency.is_nan() || frequency <= 0.0 { + return Err(bad(c, "frequency must be > 0")); + } + Ok(WiggleOpts { + spatial, + noise, + dims, + frequency, + magnitude: f_p(p, "magnitude").unwrap_or(1.0).abs(), + seed: p.get("seed").and_then(Value::as_u64).unwrap_or(0), + }) +} + +/// Shared driver: for every property with ≥ `min` selected keys, replace its keys with +/// `f(keys, first, last, spatial, layer_maps)`; select every key in each range afterwards. +#[allow(clippy::type_complexity)] +fn run_on_selection( + s: &mut Session, + label: &str, + cmd: &str, + min: usize, + f: &dyn Fn(&[Keyframe], usize, usize, bool, FrameRate, &dyn Fn(Tick) -> Tick, &dyn Fn(Tick) -> Tick, Uid) -> Vec, +) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let groups = grouped_selection(s); + if !groups.values().any(|v| v.len() >= min) { + return Err(bad(cmd, format!("select at least {min} keyframes of a property"))); + } + let (mut before, mut after) = (0usize, 0usize); + s.edit(label, None, |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let mut sel = vec![]; + for ((lid, uid), times) in groups { + let Some(l) = comp.layer_mut(lid) else { continue }; + let lc = l.clone(); + let to_comp = |t: Tick| lc.comp_time(t); + let to_layer = |t: Tick| lc.layer_time(t); + let Some(pr) = l.props.find_mut(uid) else { continue }; + if times.len() < min || !pr.value.interpolates() { + continue; + } + let (Some(a), Some(b)) = + (effectcraft_keyframe::key_at(&pr.keys, times[0]), effectcraft_keyframe::key_at(&pr.keys, *times.last().unwrap_or(×[0]))) + else { + continue; + }; + let in_range = |k: &Keyframe| k.time >= pr.keys[a].time && k.time <= pr.keys[b].time; + before += pr.keys.iter().filter(|k| in_range(k)).count(); + let (t0, t1) = (pr.keys[a].time, pr.keys[b].time); + pr.keys = f(&pr.keys, a, b, pr.spatial, fr, &to_comp, &to_layer, uid); + let n: Vec = pr.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| k.time).collect(); + after += n.len(); + sel.extend(n.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time })); + } + st.selected_keys = sel; + Ok(()) + })?; + Ok(json!({"before": before, "after": after})) +} + +fn wiggle(s: &mut Session, p: &Value) -> Result { + let o = parse_wiggle(p)?; + run_on_selection(s, "Wiggler", "keys.wiggle", 2, &|keys, a, b, spatial, fr, tc, tl, uid| wiggle_keys(keys, a, b, spatial, fr, tc, tl, &o, uid)) +} + +fn smooth(s: &mut Session, p: &Value) -> Result { + let tol = f_p(p, "tolerance").unwrap_or(1.0).max(0.0); + run_on_selection(s, "Smoother", "keys.smooth", 3, &|keys, a, b, spatial, fr, tc, tl, _| { + let samples = sample_curve(keys, keys[a].time, keys[b].time, spatial, fr, tc, tl); + smooth_keys(keys, a, b, &samples, tol, spatial) + }) +} + +/// Motion Sketch: `points` are `[t, x, y]` (t = capture seconds from the start) or `[x, y]` +/// (one per frame); recorded from `start` (default the current time). +fn sketch(s: &mut Session, p: &Value) -> Result { + let c = "motion.sketch"; + let (cid, lid) = layer_p(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let fd = fr.frame_duration().seconds(); + let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(c, "missing `points`: [[t, x, y]…] or [[x, y]…] in comp pixels"))?; + let mut rec: Vec<(f64, [f64; 2])> = vec![]; + for (i, v) in pts.iter().enumerate() { + let a: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + match a.len() { + 2 => rec.push((i as f64 * fd, [a[0], a[1]])), + 3.. => rec.push((a[0], [a[1], a[2]])), + _ => return Err(bad(c, format!("point {i}: expected [t, x, y] or [x, y]"))), + } + } + if rec.len() < 2 { + return Err(bad(c, "need at least two points")); + } + rec.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Capture speed: 100 % plays back at the recorded speed; 200 % plays back twice as slow. + let speed = f_p(p, "captureSpeed").unwrap_or(100.0).max(1.0) / 100.0; + let smoothing = f_p(p, "smoothing").unwrap_or(1.0).max(0.0); + let start = p.get("start").and_then(Value::as_f64).map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let f0 = fr.frame_at(start); + let t_rec0 = rec[0].0; + let span = (rec.last().map(|r| r.0).unwrap_or(0.0) - t_rec0) * speed; + let nframes = (span / fd).round() as i64; + let end_frame = fr.frame_at(comp.duration - comp.frame_duration()); + let at = |secs: f64| { + // Linear interpolation of the recorded path at recording time `secs`. + let i = rec.partition_point(|r| r.0 <= secs).clamp(1, rec.len() - 1); + let (a, b) = (rec[i - 1], rec[i]); + let f = if b.0 > a.0 { ((secs - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) } else { 1.0 }; + [a.1[0] + (b.1[0] - a.1[0]) * f, a.1[1] + (b.1[1] - a.1[1]) * f] + }; + let n = s.edit("Motion Sketch", None, |proj, st| { + let l = layer_mut(proj, cid, lid)?; + let lc = l.clone(); + let pos = l.props.prop_mut("transform/position").ok_or_else(|| bad(c, "the layer has no Position"))?; + let z = pos.value.as_vec3()[2]; + let three = matches!(pos.value, KValue::Vec3(_)); + let mut new: Vec = vec![]; + for k in 0..=nframes { + let f = f0 + k; + if f > end_frame { + break; + } + let q = at(t_rec0 + k as f64 * fd / speed); + let v = if three { KValue::Vec3([q[0], q[1], z]) } else { KValue::Vec2(q) }; + new.push(Keyframe::new(lc.layer_time(fr.tick_of(f)), v)); + } + let (Some(t0), Some(t1)) = (new.first().map(|k| k.time), new.last().map(|k| k.time)) else { return Ok(0) }; + let mut keys: Vec = pos.keys.iter().filter(|k| k.time < t0 || k.time > t1).cloned().collect(); + keys.extend(new); + keys.sort_by_key(|k| k.time); + let (a, b) = (keys.iter().position(|k| k.time == t0).unwrap_or(0), keys.iter().position(|k| k.time == t1).unwrap_or(0)); + if smoothing > 0.0 && b > a + 1 { + let samples: Vec<(Tick, Vec)> = keys[a..=b].iter().map(|k| (k.time, k.value.components())).collect(); + keys = smooth_keys(&keys, a, b, &samples, smoothing, true); + } + pos.keys = keys; + let uid = pos.uid; + st.selected_keys = pos.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); + Ok(st.selected_keys.len()) + })?; + Ok(json!({"keys": n})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "keys.wiggle", + "Wiggler", + [], + None, + "{apply?: spatial|temporal, noise?: smooth|jagged, dimensions?: one|same|independent, dimension? (index for `one`), frequency? (keys/s, 5), magnitude?, seed?}", + has_keys, + wiggle + ), + cmd!("keys.smooth", "Smoother", [], None, "{tolerance? (property units, 1)}", has_keys, smooth), + cmd!( + "motion.sketch", + "Motion Sketch", + [], + None, + "{layer?, points: [[t, x, y]…] (t = capture seconds) | [[x, y]…] (one per frame), start? (s, default current time), captureSpeed? (%, 100), smoothing? (px, 1)}", + has_comp, + sketch + ), + ] +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index e4c7857e..39107f25 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { fe!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], None, "{name, rename?: new name, delete?: bool}", always), fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always), fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always), - fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], None, "{}", has_comp), + fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], Some("Tab"), "{}", has_comp), fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers), fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always), fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index efc613a8..3dea6880 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -693,15 +693,65 @@ fn arrange(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition") +/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage, +/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's +/// transform, masks, effects and timing in the current comp. `adjustDuration` (move) trims the +/// new comp to the selected layers' span; `open` opens the new comp. fn precompose(s: &mut Session, p: &Value) -> Result { + let c = "layer.precompose"; let (cid, ids) = layers_p(s, p)?; if ids.is_empty() { - return Err(bad("layer.precompose", "no layers")); + return Err(bad(c, "no layers")); } let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let leave = match str_p(p, "mode").unwrap_or("move") { + "move" => false, + "leave" => true, + x => return Err(bad(c, format!("mode: move|leave, got `{x}`"))), + }; + let adjust = b_p(p, "adjustDuration").unwrap_or(false); + if leave { + let [lid] = ids[..] else { return Err(bad(c, "\"Leave all attributes\" needs exactly one layer")) }; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); + let Some(src_item) = layer.source.item() else { + return Err(bad(c, "\"Leave all attributes\" is available for footage, solid and precomp layers only")); + }; + let (w, h) = effectcraft_render::source_size(&s.project, &layer); + let (w, h) = if w == 0 || h == 0 { (comp.width, comp.height) } else { (w, h) }; + let dur = match s.project.item(src_item).map(|i| &i.kind) { + Some(ItemKind::Comp(nc)) => nc.duration, + Some(ItemKind::Footage(f)) if f.duration > Tick(0) => f.duration, + _ => comp.duration, + }; + let new = s.edit("Pre-compose", None, |proj, st| { + let mut inner = Comp::new(w, h, comp.frame_rate, dur); + inner.background = comp.background; + let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur)); + il.name = layer.name.clone(); + inner.layers.push(il); + let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); + let l = layer_mut(proj, cid, lid)?; + l.source = LayerSource::Comp { item: iid }; + l.name = name.clone(); + st.selected_layers = vec![lid]; + Ok((iid, lid)) + })?; + if b_p(p, "open").unwrap_or(false) { + s.open_comp(new.0); + } + return Ok(json!({"comp": new.0.0, "layer": new.1.0})); + } + let span = { + let sel: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).collect(); + let a = sel.iter().map(|l| l.in_point).min().unwrap_or(Tick(0)).max(Tick(0)); + let b = sel.iter().map(|l| l.out_point).max().unwrap_or(comp.duration).min(comp.duration); + (a, b) + }; + let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) }; let new = s.edit("Pre-compose", None, |proj, st| { - let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, comp.duration); + let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, dur); inner.background = comp.background; inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); for l in &mut inner.layers { @@ -711,10 +761,16 @@ fn precompose(s: &mut Session, p: &Value) -> Result { if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) { l.track_matte = None; } + l.start_time -= offset; + l.in_point -= offset; + l.out_point -= offset; } let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); - let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(comp.duration)); + let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(dur)); l.name = name.clone(); + l.start_time = offset; + l.in_point = offset; + l.out_point = offset + dur; let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); c.layers.retain(|l| !ids.contains(&l.id)); @@ -723,6 +779,9 @@ fn precompose(s: &mut Session, p: &Value) -> Result { st.selected_layers = vec![lid]; Ok((iid, lid)) })?; + if b_p(p, "open").unwrap_or(false) { + s.open_comp(new.0); + } Ok(json!({"comp": new.0.0, "layer": new.1.0})) } @@ -1039,7 +1098,15 @@ pub fn specs() -> Vec { slip(s, &p) }), cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange), - cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose), + cmd!( + "layer.precompose", + "Pre-compose...", + ["Layer"], + Some("Cmd+Shift+C"), + "{layers?, name?, mode?: move|leave, adjustDuration?, open?}", + has_layers, + precompose + ), cmd!( "layer.addMask", "New Mask", diff --git a/crates/engine/src/commands/markers.rs b/crates/engine/src/commands/markers.rs new file mode 100644 index 00000000..c9e4e285 --- /dev/null +++ b/crates/engine/src/commands/markers.rs @@ -0,0 +1,323 @@ +//! Composition and layer markers: the Composition/Layer Marker dialog (`markers.set`), dragging +//! and Alt-dragging the duration in the Timeline (`markers.set` with `merge`), deleting +//! (`markers.delete`, Ctrl/Cmd-click), converting between layer and comp markers +//! (`markers.convert`) and Layer ▸ Markers ▸ Update Markers From Source. Times in parameters are +//! comp seconds; layer markers are stored in layer time. All commands are undoable. + +use effectcraft_color::Label; +use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, merge_p, resolve_layer, str_p}; +use crate::{EngineError, Result, Session, cmd, query}; + +/// Where a marker lives. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Owner { + Comp, + Layer(LayerId), +} + +fn owner(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Owner)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + match p.get("layer") { + None | Some(Value::Null) => Ok((cid, Owner::Comp)), + Some(v) => resolve_layer(comp, v).map(|l| (cid, Owner::Layer(l))).ok_or_else(|| bad(cmd, format!("no layer {v}"))), + } +} + +fn markers_of(c: &Comp, o: Owner) -> Option<&Vec> { + match o { + Owner::Comp => Some(&c.markers), + Owner::Layer(l) => c.layer(l).map(|l| &l.markers), + } +} + +/// Comp time ↔ marker time (layer markers are in layer time). +fn to_marker_time(c: &Comp, o: Owner, comp_t: Tick) -> Tick { + match o { + Owner::Comp => comp_t, + Owner::Layer(l) => c.layer(l).map_or(comp_t, |l| l.layer_time(comp_t)), + } +} +fn to_comp_time(c: &Comp, o: Owner, t: Tick) -> Tick { + match o { + Owner::Comp => t, + Owner::Layer(l) => c.layer(l).map_or(t, |l| l.comp_time(t)), + } +} + +/// `index` (0-based) or `at` (comp seconds, within half a frame). +fn find_index(c: &Comp, o: Owner, p: &Value, cmd: &str) -> Result { + let ms = markers_of(c, o).ok_or_else(|| bad(cmd, "no such layer"))?; + if let Some(i) = p.get("index").and_then(Value::as_u64) { + return (i < ms.len() as u64).then_some(i as usize).ok_or_else(|| bad(cmd, format!("no marker #{i} ({} markers)", ms.len()))); + } + if let Some(at) = f_p(p, "at") { + let t = Tick::from_seconds_f64(at); + let half = Tick(c.frame_duration().0 / 2); + return ms.iter().position(|m| (to_comp_time(c, o, m.time) - t).0.abs() <= half.0).ok_or_else(|| bad(cmd, format!("no marker at {at} s"))); + } + Err(bad(cmd, "give the marker `index` (0-based) or `at` (comp seconds)")) +} + +fn marker_json(c: &Comp, o: Owner, i: usize, m: &Marker) -> Value { + json!({ + "index": i, + "time": to_comp_time(c, o, m.time).seconds(), + "duration": m.duration.seconds(), + "comment": m.comment, + "chapter": m.chapter, + "url": m.url, + "frameTarget": m.frame_target, + "cuePoint": m.cue_point.as_ref().map(|q| json!({"name": q.name, "navigation": q.navigation, "params": q.params})), + "protected": m.protected, + "label": m.label.name(), + }) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let (cid, o) = owner(s, p, "markers.list")?; + let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ms = markers_of(c, o).cloned().unwrap_or_default(); + Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect())) +} + +fn locked(c: &Comp, o: Owner) -> bool { + matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked)) +} + +/// Apply the dialog fields in `p` to `m`. `time`/`duration` are comp seconds. +fn apply_fields(c: &Comp, o: Owner, m: &mut Marker, p: &Value, cmd: &str) -> Result<()> { + let fr = c.frame_rate; + if let Some(t) = f_p(p, "time") { + let ct = fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0))); + m.time = to_marker_time(c, o, ct); + } + if let Some(d) = f_p(p, "duration") { + m.duration = fr.snap_nearest(Tick::from_seconds_f64(d.max(0.0))); + } + if let Some(v) = str_p(p, "comment") { + m.comment = v.into(); + } + if let Some(v) = str_p(p, "chapter") { + m.chapter = v.into(); + } + if let Some(v) = str_p(p, "url") { + m.url = v.into(); + } + if let Some(v) = str_p(p, "frameTarget") { + m.frame_target = v.into(); + } + if let Some(v) = b_p(p, "protected") { + m.protected = v; + } + if let Some(l) = p.get("label") { + m.label = match l { + Value::Number(n) => Label::ALL.get(n.as_u64().unwrap_or(0) as usize).copied(), + Value::String(s) => Label::from_name(s), + _ => None, + } + .ok_or_else(|| bad(cmd, format!("unknown label {l}")))?; + } + match p.get("cuePoint") { + Some(Value::Null) => m.cue_point = None, + Some(q @ Value::Object(_)) => { + let name = q.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); + let navigation = q.get("navigation").and_then(Value::as_bool).unwrap_or(false) || q.get("kind").and_then(Value::as_str) == Some("navigation"); + let params = q + .get("params") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|e| match e { + Value::Array(kv) => Some((kv.first()?.as_str()?.to_string(), kv.get(1).and_then(Value::as_str).unwrap_or_default().to_string())), + Value::Object(o) => { + Some((o.get("name")?.as_str()?.to_string(), o.get("value").and_then(Value::as_str).unwrap_or_default().to_string())) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + m.cue_point = Some(CuePoint { name, navigation, params }); + } + Some(v) => return Err(bad(cmd, format!("cuePoint: {{name, navigation?, params?: [[name, value]…]}} or null, got {v}"))), + None => {} + } + Ok(()) +} + +fn markers_mut(c: &mut Comp, o: Owner) -> Option<&mut Vec> { + match o { + Owner::Comp => Some(&mut c.markers), + Owner::Layer(l) => c.layer_mut(l).map(|l| &mut l.markers), + } +} + +/// Edit (or with `new: true`, add) a marker. Returns its new index. +fn set(s: &mut Session, p: &Value) -> Result { + let c = "markers.set"; + let (cid, o) = owner(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + if locked(comp, o) { + return Err(bad(c, "the layer's markers are locked")); + } + let adding = b_p(p, "new").unwrap_or(false); + let idx = if adding { None } else { Some(find_index(comp, o, p, c)?) }; + let now = s.time(); + let label = if adding { "Add Marker" } else { "Marker Settings" }; + let i = s.edit(label, merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let snapshot = comp.clone(); + let mut m = match idx { + Some(i) => markers_of(&snapshot, o).and_then(|v| v.get(i)).cloned().ok_or_else(|| bad(c, "marker vanished"))?, + None => Marker { time: to_marker_time(&snapshot, o, snapshot.frame_rate.snap_nearest(now)), ..Default::default() }, + }; + apply_fields(&snapshot, o, &mut m, p, c)?; + let v = markers_mut(comp, o).ok_or_else(|| bad(c, "no such layer"))?; + if let Some(i) = idx { + v.remove(i); + } + v.push(m.clone()); + v.sort_by_key(|x| x.time); + Ok(v.iter().position(|x| *x == m).unwrap_or(0)) + })?; + Ok(json!({"index": i})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let c = "markers.delete"; + let (cid, o) = owner(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + if locked(comp, o) { + return Err(bad(c, "the layer's markers are locked")); + } + let i = find_index(comp, o, p, c)?; + s.edit("Delete Marker", None, |proj, _| { + markers_mut(proj.comp_mut(cid).ok_or(EngineError::NoComp)?, o).ok_or_else(|| bad(c, "no such layer"))?.remove(i); + Ok(()) + })?; + Ok(Value::Null) +} + +/// Layer marker → comp marker (when `layer` is given), or comp marker → layer marker on +/// `toLayer` (default: the selected layers). +fn convert(s: &mut Session, p: &Value) -> Result { + let c = "markers.convert"; + let (cid, o) = owner(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let i = find_index(&comp, o, p, c)?; + let m = markers_of(&comp, o).and_then(|v| v.get(i)).cloned().ok_or_else(|| bad(c, "no marker"))?; + let ct = to_comp_time(&comp, o, m.time); + let targets: Vec = match o { + Owner::Layer(_) => vec![], + Owner::Comp => match p.get("toLayer") { + Some(v) => vec![resolve_layer(&comp, v).ok_or_else(|| bad(c, format!("no layer {v}")))?], + None => s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect(), + }, + }; + if o == Owner::Comp && targets.is_empty() { + return Err(bad(c, "select the layer to receive the marker (or give `toLayer`)")); + } + let label = if o == Owner::Comp { "Convert to Layer Marker" } else { "Convert to Composition Marker" }; + s.edit(label, None, |proj, _| { + let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + markers_mut(cm, o).ok_or_else(|| bad(c, "no such layer"))?.remove(i); + match o { + Owner::Layer(_) => { + cm.markers.push(Marker { time: ct, ..m.clone() }); + cm.markers.sort_by_key(|x| x.time); + } + Owner::Comp => { + for lid in &targets { + if let Some(l) = cm.layer_mut(*lid) { + let lt = l.layer_time(ct); + l.markers.push(Marker { time: lt, ..m.clone() }); + l.markers.sort_by_key(|x| x.time); + } + } + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the +/// layer (layer time is the nested comp's time), skipping ones already there. +fn update_from_source(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let src: Vec<(LayerId, Vec)> = { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + ids.iter() + .filter_map(|id| { + let l = comp.layer(*id)?; + match &l.source { + LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + _ => None, + } + }) + .collect() + }; + if src.is_empty() { + return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + } + let n = s.edit("Update Markers From Source", None, |proj, _| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut n = 0; + for (lid, ms) in &src { + let Some(l) = c.layer_mut(*lid) else { continue }; + if l.markers_locked { + continue; + } + for m in ms { + if !l.markers.iter().any(|x| x.time == m.time) { + l.markers.push(m.clone()); + n += 1; + } + } + l.markers.sort_by_key(|x| x.time); + } + Ok(n) + })?; + Ok(json!({"added": n})) +} + +fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + let c = s.active_comp().ok_or("no composition")?; + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + Ok(()) + } else { + Err("select a precomposition layer".into()) + } +} + +pub fn specs() -> Vec { + vec![ + query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list), + cmd!( + "markers.set", + "Marker Settings", + [], + None, + "{layer? (omit: comp marker), index? (0-based) | at? (comp s) | new?: true, time? (comp s), duration? (s), comment?, chapter?, url?, frameTarget?, cuePoint?: {name, navigation?, params?: [[name, value]…]} | null, protected?, label?}", + has_comp, + set + ), + cmd!("markers.delete", "Delete Marker", [], None, "{layer?, index? | at? (comp s)}", has_comp, delete), + cmd!( + "markers.convert", + "Convert Marker", + [], + None, + "{layer? (layer marker → comp marker), index? | at?, toLayer? (comp marker → layer marker)}", + has_comp, + convert + ), + cmd!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], None, "{layers?}", has_precomp_layer, update_from_source), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index cea0955b..26db85c9 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -2,6 +2,7 @@ //! shortcut and viewer/timeline gesture maps to one of these. mod anim; +pub mod anim_tools; mod animation; mod comp; mod comp_more; @@ -17,9 +18,11 @@ mod layer_menu; mod layer_time; mod link; mod lottie; +mod markers; mod mask; pub mod paint; mod paths; +mod project_items; mod prop; pub mod puppet; mod query; @@ -100,8 +103,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(text_edit::specs()); v.extend(layer_time::specs()); v.extend(prop::specs()); + v.extend(project_items::specs()); v.extend(anim::specs()); + v.extend(anim_tools::specs()); v.extend(link::specs()); + v.extend(markers::specs()); v.extend(mask::specs()); v.extend(paths::specs()); v.extend(effect::specs()); diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs new file mode 100644 index 00000000..832f5851 --- /dev/null +++ b/crates/engine/src/commands/project_items.rs @@ -0,0 +1,151 @@ +//! Project panel item edits: select, rename, move into / out of folders, label and comment. +//! Every edit is undoable. + +use effectcraft_project::{ItemId, Project}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, b_p, bad, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// `item` (id or name) → id. +fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result { + match v { + Value::Number(n) => n.as_u64().map(ItemId).filter(|i| s.project.item(*i).is_some()), + Value::String(name) => s.project.find_by_name(name).map(|i| i.id), + _ => None, + } + .ok_or_else(|| bad(cmd, format!("no project item {v}"))) +} + +/// `items` (array) / `item`, else the Project panel selection. +fn items_p(s: &Session, p: &Value, cmd: &str) -> Result> { + if let Some(Value::Array(a)) = p.get("items") { + return a.iter().map(|v| item_ref(s, v, cmd)).collect(); + } + if let Some(v) = p.get("item") { + return Ok(vec![item_ref(s, v, cmd)?]); + } + if s.state.project_selection.is_empty() { + return Err(bad(cmd, "no `items` given and nothing selected in the Project panel")); + } + Ok(s.state.project_selection.clone()) +} + +/// Whether `folder` is `item` or inside it. +pub fn is_within(project: &Project, folder: ItemId, item: ItemId) -> bool { + let mut cur = Some(folder); + let mut guard = 0; + while let Some(c) = cur { + if c == item { + return true; + } + cur = project.item(c).and_then(|i| i.parent); + guard += 1; + if guard > 256 { + return true; + } + } + false +} + +fn select(s: &mut Session, p: &Value) -> Result { + let ids = match p.get("items") { + Some(Value::Array(a)) => a.iter().map(|v| item_ref(s, v, "project.select")).collect::>>()?, + _ => vec![], + }; + if b_p(p, "add").unwrap_or(false) { + for i in ids { + if !s.state.project_selection.contains(&i) { + s.state.project_selection.push(i); + } + } + } else { + s.state.project_selection = ids; + } + Ok(json!(s.state.project_selection.iter().map(|i| i.0).collect::>())) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let c = "project.rename"; + let id = + item_ref(s, p.get("item").unwrap_or(&Value::Null), c).or_else(|_| s.state.project_selection.first().copied().ok_or_else(|| bad(c, "no `item`")))?; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing `name`"))?.to_string(); + s.edit("Rename", None, |proj, _| { + proj.item_mut(id).ok_or_else(|| bad(c, "item vanished"))?.name = name; + Ok(()) + })?; + Ok(Value::Null) +} + +/// Move items into `folder` (id/name; `null` = the project root). +fn move_items(s: &mut Session, p: &Value) -> Result { + let c = "project.move"; + let ids = items_p(s, p, c)?; + let folder = match p.get("folder") { + None | Some(Value::Null) => None, + Some(v) => { + let f = item_ref(s, v, c)?; + if !s.project.item(f).is_some_and(|i| i.is_folder()) { + return Err(bad(c, format!("{v} is not a folder"))); + } + Some(f) + } + }; + if let Some(f) = folder + && let Some(bad_id) = ids.iter().find(|i| is_within(&s.project, f, **i)) + { + return Err(bad(c, format!("can't move folder {} into itself", bad_id.0))); + } + s.edit("Move Items", None, |proj, _| { + for i in &ids { + if let Some(it) = proj.item_mut(*i) { + it.parent = folder; + } + } + Ok(()) + })?; + Ok(json!({"moved": ids.len()})) +} + +fn set_label(s: &mut Session, p: &Value) -> Result { + let c = "project.setLabel"; + let ids = items_p(s, p, c)?; + let lab = match p.get("label") { + Some(Value::Number(n)) => effectcraft_color::Label::ALL.get(n.as_u64().unwrap_or(0) as usize).copied(), + Some(Value::String(name)) => s.prefs.label_from_name(name), + _ => None, + } + .ok_or_else(|| bad(c, "missing or unknown `label` (name or index 0-16)"))?; + s.edit("Label", None, |proj, _| { + for i in &ids { + if let Some(it) = proj.item_mut(*i) { + it.label = lab; + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn set_comment(s: &mut Session, p: &Value) -> Result { + let c = "project.setComment"; + let ids = items_p(s, p, c)?; + let text = str_p(p, "comment").ok_or_else(|| bad(c, "missing `comment`"))?.to_string(); + s.edit("Comment", None, |proj, _| { + for i in &ids { + proj.item_mut(*i).ok_or(EngineError::Other("item vanished".into()))?.comment = text.clone(); + } + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select), + cmd!("project.rename", "Rename Item", [], None, "{item?: id|name (default: selected), name}", always, rename), + cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items), + cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label), + cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment), + ] +} diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 0c9afe94..f7868d9c 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -34,7 +34,6 @@ pub fn specs() -> Vec { // Time (timeline milestone). stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"), stub!("layer.environment", "Environment Layer", ["Layer"], "{}"), - stub!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], "{}"), stub!("layer.create", "Create", [], "{op}"), stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"), stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d18cd426..7192f7fe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -538,14 +538,20 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] +mod tests_anim_tools; +#[cfg(test)] mod tests_effects; #[cfg(test)] mod tests_fidelity; #[cfg(test)] mod tests_lottie; #[cfg(test)] +mod tests_markers; +#[cfg(test)] mod tests_menu_cmds; #[cfg(test)] +mod tests_project_items; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stubs; diff --git a/crates/engine/src/tests_anim_tools.rs b/crates/engine/src/tests_anim_tools.rs new file mode 100644 index 00000000..69b6afa0 --- /dev/null +++ b/crates/engine/src/tests_anim_tools.rs @@ -0,0 +1,113 @@ +//! Wiggler, Smoother and Motion Sketch (`keys.wiggle`, `keys.smooth`, `motion.sketch`). + +use effectcraft_keyframe::evaluate; +use serde_json::json; + +use crate::tests_timeline::{animate, prop, setup}; + +fn select(s: &mut crate::Session, layer: u64, path: &str) { + s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap(); +} + +#[test] +fn wiggle_adds_keys_at_frequency_within_magnitude() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([100, 100])), (2.0, json!([300, 100]))]); + let orig = prop(&s, l, "transform/position"); + select(&mut s, l, "transform/position"); + let opts = json!({"apply": "spatial", "noise": "jagged", "dimensions": "independent", "frequency": 5, "magnitude": 20, "seed": 7}); + let r = s.execute("keys.wiggle", opts.clone()).unwrap(); + let p = prop(&s, l, "transform/position"); + // 2 s at 5 keys/s: 9 keys between the two originals (one every 6 frames at 30 fps). + assert_eq!(p.keys.len(), 11, "{r}"); + assert_eq!(r["after"], 11); + let step = p.keys[2].time - p.keys[1].time; + assert!((step.seconds() - 0.2).abs() < 1e-9); + for k in &p.keys[1..10] { + let base = evaluate(&orig.keys, k.time, true).unwrap().as_vec2(); + let v = k.value.as_vec2(); + assert!((v[0] - base[0]).abs() <= 20.0 + 1e-9 && (v[1] - base[1]).abs() <= 20.0 + 1e-9, "{v:?} vs {base:?}"); + } + assert!(p.keys[1..10].iter().any(|k| (k.value.as_vec2()[1] - 100.0).abs() > 1.0), "noise was applied"); + // End keys untouched; deterministic for the same seed; undo restores. + assert_eq!(p.keys[0].value, orig.keys[0].value); + assert_eq!(p.keys[10].value, orig.keys[1].value); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/position").keys.len(), 2); + select(&mut s, l, "transform/position"); + s.execute("keys.wiggle", opts).unwrap(); + assert_eq!(prop(&s, l, "transform/position").keys, p.keys); +} + +#[test] +fn wiggle_one_dimension_and_smooth_noise() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([100, 100])), (1.0, json!([100, 100]))]); + select(&mut s, l, "transform/position"); + s.execute("keys.wiggle", json!({"dimensions": "one", "dimension": 1, "frequency": 10, "magnitude": 50})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert_eq!(p.keys.len(), 11); + for k in &p.keys { + let v = k.value.as_vec2(); + assert!((v[0] - 100.0).abs() < 1e-9); + assert!((v[1] - 100.0).abs() <= 50.0); + } + // Opacity (non-spatial): the temporal path deviates the value. + animate(&mut s, l, "transform/opacity", &[(0.0, json!(50)), (1.0, json!(50))]); + select(&mut s, l, "transform/opacity"); + s.execute("keys.wiggle", json!({"apply": "temporal", "frequency": 4, "magnitude": 10})).unwrap(); + let o = prop(&s, l, "transform/opacity"); + assert!(o.keys.len() >= 5); + assert!(o.keys.iter().all(|k| (k.value.as_f64() - 50.0).abs() <= 10.0)); + assert!(s.execute("keys.wiggle", json!({"noise": "wobbly"})).is_err()); +} + +#[test] +fn smoother_reduces_keys_within_tolerance() { + let (mut s, l) = setup(); + // A ramp keyed every frame with a little jitter. + let keys: Vec<(f64, serde_json::Value)> = (0..=60).map(|f| (f as f64 / 30.0, json!(f as f64 * 1.5 + if f % 2 == 0 { 0.2 } else { -0.2 }))).collect(); + animate(&mut s, l, "transform/opacity", &keys); + let orig = prop(&s, l, "transform/opacity"); + assert_eq!(orig.keys.len(), 61); + select(&mut s, l, "transform/opacity"); + let r = s.execute("keys.smooth", json!({"tolerance": 1.0})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys.len() < 10, "{} keys ({r})", p.keys.len()); + for f in 0..=60 { + let t = s.active_comp().unwrap().frame_rate.tick_of(f); + let a = evaluate(&orig.keys, t, false).unwrap().as_f64(); + let b = evaluate(&p.keys, t, false).unwrap().as_f64(); + assert!((a - b).abs() <= 1.0 + 1e-6, "frame {f}: {a} vs {b}"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys.len(), 61); +} + +#[test] +fn motion_sketch_records_position_keys() { + let (mut s, l) = setup(); + // 1 s of capture: a straight line from (0,0) to (300,150), sampled every 0.1 s. + let pts: Vec = (0..=10).map(|i| json!([i as f64 * 0.1, i as f64 * 30.0, i as f64 * 15.0])).collect(); + let r = s.execute("motion.sketch", json!({"layer": l, "points": pts, "start": 0.5, "smoothing": 0})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert_eq!(r["keys"], 31); + assert_eq!(p.keys.len(), 31); + assert_eq!(p.keys[0].time.seconds(), 0.5); + assert_eq!(p.keys[0].value.as_vec2(), [0.0, 0.0]); + let last = p.keys[30].value.as_vec2(); + assert!((last[0] - 300.0).abs() < 1e-6 && (last[1] - 150.0).abs() < 1e-6); + // Smoothing collapses the straight line to (almost) its end points; 200 % capture speed + // plays back over twice the time. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("motion.sketch", json!({"layer": l, "points": pts, "start": 0, "smoothing": 2, "captureSpeed": 200})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.len() <= 4, "{}", p.keys.len()); + assert!((p.keys.last().unwrap().time.seconds() - 2.0).abs() < 1e-9); + // [x, y] points: one per frame. + s.execute("motion.sketch", json!({"layer": l, "points": [[0, 0], [10, 0], [20, 0]], "start": 3, "smoothing": 0})).unwrap(); + let p = prop(&s, l, "transform/position"); + let t = s.active_comp().unwrap().frame_rate.tick_of(92); + assert!(p.keys.iter().any(|k| k.time == t && k.value.as_vec2() == [20.0, 0.0])); + assert!(s.execute("motion.sketch", json!({"layer": l, "points": [[1, 2]]})).is_err()); +} diff --git a/crates/engine/src/tests_markers.rs b/crates/engine/src/tests_markers.rs new file mode 100644 index 00000000..c87c3815 --- /dev/null +++ b/crates/engine/src/tests_markers.rs @@ -0,0 +1,149 @@ +//! Composition / layer markers: dialog fields, move, duration, delete, convert, update from +//! source, all with undo. + +use serde_json::json; + +use crate::tests_timeline::setup; + +#[test] +fn comp_marker_dialog_fields_and_undo() { + let (mut s, _) = setup(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("comp.addMarker", json!({"comment": "a"})).unwrap(); + let r = s + .execute( + "markers.set", + json!({"index": 0, "time": 1.51, "duration": 0.5, "comment": "Intro", "chapter": "One", "url": "https://getartcraft.com", "frameTarget": "_blank", + "cuePoint": {"name": "cue", "navigation": true, "params": [["k", "v"]]}, "protected": true, "label": "Aqua"}), + ) + .unwrap(); + assert_eq!(r["index"], 0); + let l = s.execute("markers.list", json!({})).unwrap(); + let m = &l[0]; + // Snapped to whole frames (30 fps). + assert!((m["time"].as_f64().unwrap() - 1.5).abs() < 1e-9, "{m}"); + assert!((m["duration"].as_f64().unwrap() - 0.5).abs() < 1e-9); + assert_eq!(m["comment"], "Intro"); + assert_eq!(m["chapter"], "One"); + assert_eq!(m["url"], "https://getartcraft.com"); + assert_eq!(m["frameTarget"], "_blank"); + assert_eq!(m["cuePoint"]["name"], "cue"); + assert_eq!(m["cuePoint"]["navigation"], true); + assert_eq!(m["cuePoint"]["params"][0][1], "v"); + assert_eq!(m["protected"], true); + assert_eq!(m["label"], "Aqua"); + s.execute("edit.undo", json!({})).unwrap(); + let l = s.execute("markers.list", json!({})).unwrap(); + assert_eq!(l[0]["comment"], "a"); + assert_eq!(l[0]["time"], 1.0); + s.execute("edit.redo", json!({})).unwrap(); + // Find by time, clear the cue point, delete with undo. + s.execute("markers.set", json!({"at": 1.5, "cuePoint": null})).unwrap(); + assert!(s.execute("markers.list", json!({})).unwrap()[0]["cuePoint"].is_null()); + s.execute("markers.delete", json!({"at": 1.5})).unwrap(); + assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 1); + assert!(s.execute("markers.set", json!({"index": 5, "comment": "x"})).is_err()); +} + +#[test] +fn drag_merges_into_one_undo_step_and_markers_stay_sorted() { + let (mut s, _) = setup(); + for t in [0.5, 2.0] { + s.execute("markers.set", json!({"new": true, "time": t})).unwrap(); + } + let before = s.history.undo.len(); + // A drag: several moves merged under one key; passing the other marker re-sorts. + let mut idx = 0; + for t in [1.0, 1.5, 2.5] { + idx = s.execute("markers.set", json!({"index": idx, "time": t, "merge": "mk-drag"})).unwrap()["index"].as_u64().unwrap(); + } + assert_eq!(idx, 1); + assert_eq!(s.history.undo.len(), before + 1); + let l = s.execute("markers.list", json!({})).unwrap(); + assert_eq!(l[0]["time"], 2.0); + assert_eq!(l[1]["time"], 2.5); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("markers.list", json!({})).unwrap()[0]["time"], 0.5); +} + +#[test] +fn layer_markers_convert_and_update_from_source() { + let (mut s, l) = setup(); + // Layer starts at 1 s: layer markers are stored in layer time but addressed in comp time. + s.execute("layer.timing", json!({"layers": [l], "start": 1.0})).unwrap(); + s.execute("markers.set", json!({"layer": l, "new": true, "time": 2.0, "comment": "hit"})).unwrap(); + let c = s.active_comp().unwrap(); + let lay = c.layer(effectcraft_project::LayerId(l)).unwrap(); + assert_eq!(lay.markers[0].time.seconds(), 1.0); + assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap()[0]["time"], 2.0); + // Layer → comp marker keeps the comp time. + s.execute("markers.convert", json!({"layer": l, "index": 0})).unwrap(); + assert_eq!(s.execute("markers.list", json!({})).unwrap()[0]["time"], 2.0); + assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap().as_array().unwrap().len(), 0); + // And back. + s.execute("markers.convert", json!({"index": 0, "toLayer": l})).unwrap(); + assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap()[0]["comment"], "hit"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 1); + // Locked markers refuse edits. + s.execute("layer.markersLock", json!({"layers": [l], "value": true})).unwrap(); + assert!(s.execute("markers.set", json!({"layer": l, "new": true})).is_err()); + s.execute("layer.markersLock", json!({"layers": [l], "value": false})).unwrap(); + + // Update Markers From Source on a precomp layer. + let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap(); + let inner = r["comp"].as_u64().unwrap(); + let outer_layer = r["layer"].as_u64().unwrap(); + s.execute("markers.set", json!({"comp": inner, "new": true, "time": 0.5, "comment": "src"})).unwrap(); + s.execute("layer.select", json!({"layers": [outer_layer]})).unwrap(); + let r = s.execute("layer.updateMarkersFromSource", json!({})).unwrap(); + assert_eq!(r["added"], 1); + let lm = s.execute("markers.list", json!({"layer": outer_layer})).unwrap(); + assert!(lm.as_array().unwrap().iter().any(|m| m["comment"] == "src")); + // Idempotent; undoable. + assert_eq!(s.execute("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.execute("markers.list", json!({"layer": outer_layer})).unwrap().as_array().unwrap().iter().any(|m| m["comment"] == "src")); +} + +#[test] +fn precompose_leave_and_move_attributes() { + use effectcraft_project::{ItemId, LayerId, LayerSource}; + let (mut s, l) = setup(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [50, 60]})).unwrap(); + let outer = s.active_comp_id().unwrap(); + // Leave all attributes: same layer, keeps its transform; the new comp is the source size and + // holds a default layer of the original source. + let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Leave", "mode": "leave"})).unwrap(); + assert_eq!(r["layer"], l); + let inner = ItemId(r["comp"].as_u64().unwrap()); + let lay = s.project.comp(outer).unwrap().layer(LayerId(l)).unwrap().clone(); + assert_eq!(lay.source, LayerSource::Comp { item: inner }); + assert_eq!(lay.props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 60.0]); + let ic = s.project.comp(inner).unwrap(); + assert_eq!((ic.width, ic.height), (100, 100)); + assert_eq!(ic.layers.len(), 1); + assert_eq!(ic.layers[0].props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 50.0]); + assert!(matches!(ic.layers[0].source, LayerSource::Solid { .. })); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.comp(inner).is_none()); + assert!(matches!(s.project.comp(outer).unwrap().layer(LayerId(l)).unwrap().source, LayerSource::Solid { .. })); + // Leave needs one layer with a source item. + let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("layer.precompose", json!({"layers": [t], "mode": "leave"})).is_err()); + assert!(s.execute("layer.precompose", json!({"layers": [t, l], "mode": "leave"})).is_err()); + // Move all attributes + adjust duration + open: the layer moves inside, trimmed to its span. + s.execute("layer.timing", json!({"layers": [l], "in": 1.0, "out": 3.0})).unwrap(); + let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Move", "adjustDuration": true, "open": true})).unwrap(); + let inner = ItemId(r["comp"].as_u64().unwrap()); + let ic = s.project.comp(inner).unwrap(); + assert!((ic.duration.seconds() - 2.0).abs() < 1e-9); + assert_eq!(ic.layers[0].props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 60.0]); + assert_eq!(ic.layers[0].in_point.seconds(), 0.0); + let nl = s.project.comp(outer).unwrap().layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().clone(); + assert_eq!((nl.in_point.seconds(), nl.out_point.seconds()), (1.0, 3.0)); + assert_eq!(s.active_comp_id(), Some(inner)); +} diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs new file mode 100644 index 00000000..4d097aca --- /dev/null +++ b/crates/engine/src/tests_project_items.rs @@ -0,0 +1,41 @@ +//! Project panel item edits: move into folders, rename, label, comment, with undo. + +use effectcraft_project::ItemId; +use serde_json::json; + +use crate::Session; + +#[test] +fn move_into_folder_rename_label_comment_with_undo() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "A", "width": 64, "height": 64, "frameRate": 24, "duration": 1})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let f = ItemId(s.execute("project.newFolder", json!({"name": "Shots"})).unwrap()["item"].as_u64().unwrap()); + let g = ItemId(s.execute("project.newFolder", json!({"name": "Inner", "parent": f.0})).unwrap()["item"].as_u64().unwrap()); + + s.execute("project.move", json!({"items": [comp.0], "folder": "Shots"})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().parent, Some(f)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().parent, None); + s.execute("edit.redo", json!({})).unwrap(); + // Out of the folder (to the root) via the selection. + s.execute("project.select", json!({"items": [comp.0]})).unwrap(); + s.execute("project.move", json!({"folder": null})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().parent, None); + // A folder can't go into itself or its descendants. + assert!(s.execute("project.move", json!({"items": [f.0], "folder": g.0})).is_err()); + assert!(s.execute("project.move", json!({"items": [comp.0], "folder": comp.0})).is_err()); + + s.execute("project.rename", json!({"item": comp.0, "name": "Hero"})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().name, "Hero"); + assert!(s.execute("project.rename", json!({"item": comp.0, "name": " "})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().name, "A"); + + s.execute("project.setLabel", json!({"items": [comp.0], "label": "Pink"})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().label.name(), "Pink"); + s.execute("project.setComment", json!({"item": comp.0, "comment": "final"})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().comment, "final"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.item(comp).unwrap().comment, ""); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 88fc6d02..fad56bb4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -144,6 +144,24 @@ pub struct Marker { pub url: String, #[serde(default)] pub protected: bool, + /// Web link frame target (`_blank`, a frame name…). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub frame_target: String, + /// Flash/video cue point (Event or Navigation) with name and parameters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cue_point: Option, +} + +/// A marker's cue point (Composition/Layer Marker dialog ▸ Cue Point). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CuePoint { + pub name: String, + /// Navigation (true) or Event cue point. + #[serde(default)] + pub navigation: bool, + /// (name, value) parameter pairs. + #[serde(default)] + pub params: Vec<(String, String)>, } /// 3D renderer of a composition. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 022690a0..d60b2ed2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -182,6 +182,25 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(b) = p.get("menuBar").and_then(Value::as_bool) { app.ui.show_menu_bar = b; } + // Project panel optional columns, e.g. ["type", "duration", "path", "comment"]. + if let Some(Value::Array(a)) = p.get("projectColumns") { + let mut cols = vec![]; + for k in a.iter().filter_map(Value::as_str) { + crate::panels::project::set_column(&mut cols, k, true); + } + app.ui.project_columns = cols; + } + // Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`). + if let Some(Value::Object(m)) = p.get("animTools") { + let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default(); + for (k, v) in m { + cur[k] = v.clone(); + } + match serde_json::from_value(cur) { + Ok(v) => app.ui.anim_tools = v, + Err(e) => return err(format!("animTools: {e}")), + } + } if let Some(b) = p.get("home").and_then(Value::as_bool) { app.ui.start_screen = b; } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index baa43ed7..5bb9c8b6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -381,6 +381,204 @@ impl DockNode { } } +/// Where a dragged panel tab lands on a group: as a tab (center) or split off to one side. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Zone { + Center, + Left, + Right, + Top, + Bottom, +} + +impl Zone { + pub fn from_name(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "center" | "tab" => Zone::Center, + "left" => Zone::Left, + "right" => Zone::Right, + "top" => Zone::Top, + "bottom" => Zone::Bottom, + _ => return None, + }) + } +} + +/// The drop zone for a pointer at `pos` over a group `rect` (with a `tab_h` tab strip): the tab +/// strip and the middle of the group are Center; within a quarter of the size from an edge, +/// the nearest edge. +pub fn drop_zone(rect: Rect, tab_h: f32, pos: egui::Pos2) -> Zone { + if pos.y < rect.min.y + tab_h { + return Zone::Center; + } + let fx = ((pos.x - rect.min.x) / rect.width().max(1.0)).clamp(0.0, 1.0); + let fy = ((pos.y - rect.min.y) / rect.height().max(1.0)).clamp(0.0, 1.0); + let d = [(fx, Zone::Left), (1.0 - fx, Zone::Right), (fy, Zone::Top), (1.0 - fy, Zone::Bottom)]; + let (m, z) = d.into_iter().min_by(|a, b| a.0.total_cmp(&b.0)).unwrap_or((1.0, Zone::Center)); + if m > 0.25 { Zone::Center } else { z } +} + +/// The part of a group `rect` a drop into `zone` would occupy (the AE-style highlight). +pub fn zone_rect(rect: Rect, zone: Zone) -> Rect { + let c = rect.center(); + match zone { + Zone::Center => rect, + Zone::Left => Rect::from_min_max(rect.min, pos2(c.x, rect.max.y)), + Zone::Right => Rect::from_min_max(pos2(c.x, rect.min.y), rect.max), + Zone::Top => Rect::from_min_max(rect.min, pos2(rect.max.x, c.y)), + Zone::Bottom => Rect::from_min_max(pos2(rect.min.x, c.y), rect.max), + } +} + +/// A panel group floating over the dock (undocked). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Floating { + pub panels: Vec, + pub active: usize, + /// [x, y, w, h] in points. + pub rect: [f32; 4], +} + +impl DockNode { + /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it + /// (Center) or in a new group split off to one side. `p` is removed from its old place + /// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. + pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { + if p == anchor || !self.contains(anchor) { + return false; + } + let before = self.clone(); + self.close(p); + fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { + let here = match n { + DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone), + DockNode::Tabs { panels, .. } => panels.contains(&anchor), + DockNode::Stack { entries } => entries.iter().any(|e| e.panel == anchor), + }; + if !here { + return false; + } + match (zone, &mut *n) { + (Zone::Center, DockNode::Tabs { panels, active }) => { + let i = panels.iter().position(|x| *x == anchor).map_or(panels.len(), |i| i + 1); + panels.insert(i, p); + *active = i; + } + (Zone::Center, DockNode::Stack { entries }) => { + let i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1); + entries.insert(i, StackEntry { panel: p, open: true, height: None }); + } + (z, node) => { + let new = tabs(&[p], 0); + let old = std::mem::replace(node, tabs(&[], 0)); + let vertical = matches!(z, Zone::Top | Zone::Bottom); + let (a, b) = if matches!(z, Zone::Left | Zone::Top) { (new, old) } else { (old, new) }; + *node = DockNode::Split { vertical, size: SplitSize::Ratio(0.5), a: Box::new(a), b: Box::new(b) }; + } + } + true + } + if rec(self, p, anchor, zone) { + true + } else { + *self = before; + false + } + } + + /// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`). + pub fn path_of(&self, p: PanelKind) -> Option { + fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option { + match n { + DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))), + DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()), + DockNode::Stack { entries } => entries.iter().position(|e| e.panel == p).map(|i| format!("{path}s{i}")), + } + } + rec(self, p, "") + } + + /// Number of panels in the tree. + pub fn panel_count(&self) -> usize { + let mut v = vec![]; + self.panels(&mut v); + v.len() + } +} + +/// A full layout: the docked tree plus floating panels (what a workspace saves). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Layout { + pub root: DockNode, + #[serde(default)] + pub floating: Vec, +} + +impl Layout { + /// Undock `p` into a floating group at `rect` (no-op if it is the last docked panel). + pub fn float(&mut self, p: PanelKind, rect: [f32; 4]) -> bool { + if self.root.contains(p) { + if self.root.panel_count() <= 1 { + return false; + } + self.root.close(p); + } else if !self.unfloat(p) { + return false; + } + self.floating.push(Floating { panels: vec![p], active: 0, rect }); + true + } + + /// Remove `p` from the floating groups (dropping emptied groups). True if it was floating. + pub fn unfloat(&mut self, p: PanelKind) -> bool { + let mut found = false; + for f in &mut self.floating { + if let Some(i) = f.panels.iter().position(|x| *x == p) { + f.panels.remove(i); + f.active = f.active.min(f.panels.len().saturating_sub(1)); + found = true; + } + } + self.floating.retain(|f| !f.panels.is_empty()); + found + } + + /// Dock `p` (docked or floating) next to `anchor`; `anchor` may be floating too (then Center + /// adds a tab to that floating group). + pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { + if p == anchor { + return false; + } + if self.floating.iter().any(|f| f.panels.contains(&anchor)) { + if zone != Zone::Center { + return false; + } + if self.root.contains(p) { + if self.root.panel_count() <= 1 { + return false; + } + self.root.close(p); + } else { + self.unfloat(p); + } + let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false }; + f.panels.push(p); + f.active = f.panels.len() - 1; + return true; + } + if !self.root.contains(anchor) { + return false; + } + // A floating panel isn't in the tree, so dock_panel's removal is a no-op for it. + self.unfloat(p); + self.root.dock_panel(p, anchor, zone) + } + + pub fn contains(&self, p: PanelKind) -> bool { + self.root.contains(p) || self.floating.iter().any(|f| f.panels.contains(&p)) + } +} + /// One laid-out tab group. pub struct Group { pub path: String, @@ -401,6 +599,8 @@ pub enum DockAction { Focus(PanelKind), Close(PanelKind), PanelMenu(PanelKind, egui::Pos2), + /// A tab started being dragged (re-dock or undock). + BeginDrag(PanelKind), } /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. @@ -521,7 +721,10 @@ pub fn draw_group_chrome( break; } let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); - let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()); + let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()); + if resp.drag_started() { + actions.push(DockAction::BeginDrag(*p)); + } reg.add(&format!("panel.tab.{}", p.id()), tab, &label); let label_x = tab.min.x + 8.0; let label_w = galley.size().x; @@ -613,6 +816,84 @@ mod tests { } } + #[test] + fn drop_zone_math() { + let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(400.0, 300.0)); + let tab = 28.0; + assert_eq!(drop_zone(r, tab, pos2(300.0, 110.0)), Zone::Center, "tab strip"); + assert_eq!(drop_zone(r, tab, r.center()), Zone::Center); + assert_eq!(drop_zone(r, tab, pos2(120.0, 250.0)), Zone::Left); + assert_eq!(drop_zone(r, tab, pos2(490.0, 250.0)), Zone::Right); + assert_eq!(drop_zone(r, tab, pos2(300.0, 140.0)), Zone::Top); + assert_eq!(drop_zone(r, tab, pos2(300.0, 390.0)), Zone::Bottom); + // Corners go to the nearer edge. + assert_eq!(drop_zone(r, tab, pos2(105.0, 395.0)), Zone::Left); + assert_eq!(zone_rect(r, Zone::Right), Rect::from_min_max(pos2(300.0, 100.0), r.max)); + assert_eq!(zone_rect(r, Zone::Top).height(), 150.0); + assert_eq!(zone_rect(r, Zone::Center), r); + } + + #[test] + fn dock_panel_splits_and_inserts_tabs() { + use PanelKind::*; + let mut d = workspace("Standard"); + let n = d.panel_count(); + // Tab next to an anchor. + assert!(d.dock_panel(Info, Timeline, Zone::Center)); + assert_eq!(d.panel_count(), n); + let p = d.path_of(Timeline).unwrap(); + assert_eq!(d.path_of(Info).unwrap(), p); + assert!(d.is_visible(Info)); + // Split to the right of the Composition group: a new single-tab group beside it. + assert!(d.dock_panel(Info, Composition, Zone::Right)); + let comp_path = d.path_of(Composition).unwrap(); + let info_path = d.path_of(Info).unwrap(); + assert_eq!(comp_path.len(), info_path.len()); + assert!(comp_path.ends_with('a') && info_path.ends_with('b')); + assert!(d.is_visible(Info)); + // Top: the new group comes first in a vertical split. + assert!(d.dock_panel(Audio, Timeline, Zone::Top)); + assert!(d.path_of(Audio).unwrap().ends_with('a')); + // Dropping a panel on itself or an undocked anchor changes nothing. + let before = d.clone(); + assert!(!d.dock_panel(Audio, Audio, Zone::Left)); + assert!(!d.dock_panel(Audio, Tracker, Zone::Left)); + assert_eq!(d, before); + // Into a stack. + let mut s = workspace("Default"); + assert!(s.dock_panel(Tracker, Properties, Zone::Center)); + assert!(s.path_of(Tracker).unwrap().contains('s')); + // Moving the only panel of a group collapses its split. + let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0)); + assert!(m.dock_panel(Project, Composition, Zone::Center)); + assert_eq!(m, tabs(&[Composition, Project], 1)); + } + + #[test] + fn floating_layout_round_trips() { + use PanelKind::*; + let mut l = Layout { root: workspace("Standard"), floating: vec![] }; + assert!(l.float(Info, [200.0, 150.0, 320.0, 240.0])); + assert!(!l.root.contains(Info) && l.contains(Info)); + // Tab another panel into the floating group, then dock it back. + assert!(l.dock(Audio, Info, Zone::Center)); + assert_eq!(l.floating[0].panels, [Info, Audio]); + assert!(!l.dock(Audio, Info, Zone::Left), "floating groups only take tabs"); + assert!(l.dock(Info, Timeline, Zone::Bottom)); + assert_eq!(l.floating[0].panels, [Audio]); + assert!(l.root.contains(Info)); + let s = serde_json::to_string(&l).unwrap(); + let back: Layout = serde_json::from_str(&s).unwrap(); + assert_eq!(back, l); + // A workspace saved before floating panels existed still loads. + let old: Layout = serde_json::from_str(&format!("{{\"root\": {}}}", serde_json::to_string(&l.root).unwrap())).unwrap(); + assert!(old.floating.is_empty()); + // The last docked panel can't float. + let mut one = Layout { root: tabs(&[Timeline], 0), floating: vec![] }; + assert!(!one.float(Timeline, [0.0; 4])); + assert!(l.unfloat(Audio) && l.floating.is_empty()); + } + #[test] fn close_and_open() { let mut d = workspace("Default"); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs new file mode 100644 index 00000000..44900918 --- /dev/null +++ b/crates/ui-egui/src/dock_ui.rs @@ -0,0 +1,267 @@ +//! The dock area: lays out the docked tree (or the maximized panel), draws each group's chrome +//! and body, floating (undocked) panel groups over it, and tab drag-and-drop: drag a tab onto a +//! group to tab it there (center) or split it off to a side (After Effects' drop zones, with a +//! highlight of where it will land); hold Cmd/Ctrl when releasing, or drop outside every group, +//! to float it. `` ` `` maximizes the panel under the pointer. + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; + +use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, Zone}; +use crate::{EffectcraftApp, panels}; + +fn drag_id() -> egui::Id { + egui::Id::new("dock-tab-drag") +} + +impl EffectcraftApp { + /// Tab labels that name what the panel shows, as After Effects does: "Composition Intro", + /// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp. + fn tab_titles(&self) -> Vec<(PanelKind, String)> { + let mut out = Vec::new(); + let Some(comp) = self.session.active_comp() else { return out }; + let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); + out.push((PanelKind::Composition, format!("Composition {cname}"))); + out.push((PanelKind::Timeline, cname)); + if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { + out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); + out.push((PanelKind::Properties, format!("Properties: {}", l.name))); + } + out + } + + /// Edit the full layout (docked tree + floating groups) with a [`Layout`] operation. + pub fn edit_layout(&mut self, f: impl FnOnce(&mut Layout) -> bool) -> bool { + let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() }; + if !f(&mut l) { + return false; + } + self.ui.dock = l.root; + self.ui.floating = l.floating; + if self.ui.maximized.is_some_and(|m| !self.ui.dock.contains(m)) { + self.ui.maximized = None; + } + true + } + + /// Close a panel wherever it is (docked or floating). + pub fn close_panel(&mut self, p: PanelKind) { + self.ui.dock.close(p); + self.edit_layout(|l| l.unfloat(p)); + if self.ui.maximized == Some(p) { + self.ui.maximized = None; + } + } + + /// Toggle the maximized panel (`p`, or none). + pub fn toggle_maximize(&mut self, p: PanelKind) { + self.ui.maximized = if self.ui.maximized == Some(p) || !self.ui.dock.contains(p) { None } else { Some(p) }; + } + + /// The docked panel whose group is under `pos` (from the last frame), else the focused one. + pub fn panel_at(&self, pos: Option) -> PanelKind { + pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused) + } + + fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { + self.auto.add(&format!("panel.{}", p.id()), rect, p.title()); + let mut content = rect; + if p == PanelKind::Composition && !self.ui.start_screen && panels::precomp::has_flow(self) { + // Composition Navigator: the flow of nested comps above the viewer. + let nav = Rect::from_min_size(content.min, vec2(content.width(), panels::precomp::NAV_H)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator")); + child.set_clip_rect(nav.intersect(ui.clip_rect())); + panels::precomp::navigator(self, &mut child, nav); + content.min.y = nav.max.y; + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id()))); + child.set_clip_rect(content.intersect(ui.clip_rect())); + panels::show(self, &mut child, p, content); + if p == PanelKind::Composition && !self.ui.start_screen { + panels::anim_tools::sketch_overlay(self, &mut child); + } + } + + pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { + let t = self.tokens; + let ctx = ui.ctx().clone(); + // The maximized panel fills the area; the tree is kept as it is. + let maximized = self.ui.maximized.filter(|m| self.ui.dock.contains(*m)); + let mut dock = match maximized { + Some(m) => DockNode::Tabs { panels: vec![m], active: 0 }, + None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }), + }; + let mut groups = Vec::new(); + dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); + let mut actions = Vec::new(); + let titles = self.tab_titles(); + let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + for g in &groups { + actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); + } + if maximized.is_none() { + self.ui.dock = dock; + } + self.dock_rects = groups.iter().filter_map(|g| Some((*g.panels.get(g.active)?, g.rect))).collect(); + for g in &groups { + let Some(p) = g.panels.get(g.active).copied() else { continue }; + if g.content.height() < 2.0 { + continue; // a collapsed stacked panel: header only + } + self.panel_body(ui, p, g.content); + } + // Floating groups over the dock. + let mut float_groups: Vec = vec![]; + for i in 0..self.ui.floating.len() { + if let Some(g) = self.floating_window(&ctx, i, &title, &mut actions) { + float_groups.push(g); + } + } + for a in actions { + match a { + DockAction::Activate(p) => { + if !self.ui.dock.activate(p) + && let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p)) + { + f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0); + } + } + DockAction::ToggleStacked(p) => { + self.ui.dock.toggle_stacked(p); + } + DockAction::Focus(p) => self.ui.focused = p, + DockAction::Close(p) => self.close_panel(p), + DockAction::PanelMenu(p, pos) => { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos))); + } + DockAction::BeginDrag(p) => ctx.data_mut(|d| { + d.insert_temp(drag_id(), p); + }), + } + } + self.tab_drag(&ctx, &groups, &float_groups); + panels::panel_menu_popup(self, ui); + } + + /// One floating group: a window with a tab strip (drag the empty strip to move it, the + /// corner to resize it) and the active panel's body. + fn floating_window(&mut self, ctx: &egui::Context, i: usize, title: &dyn Fn(PanelKind) -> String, actions: &mut Vec) -> Option { + let t = self.tokens; + let f = self.ui.floating.get(i)?.clone(); + let screen = ctx.content_rect(); + let mut rect = Rect::from_min_size(pos2(f.rect[0], f.rect[1]), vec2(f.rect[2].max(160.0), f.rect[3].max(100.0))); + // Keep the strip reachable. + rect = rect.translate(vec2( + (screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0), + (screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0), + )); + let id = egui::Id::new(("floating-panel", f.panels.first().map(|p| p.id()).unwrap_or_default())); + let mut group = None; + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).interactable(true).show(ctx, |ui| { + let (r, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); + ui.painter().rect_filled(r.expand(1.0), t.radius, Color32::from_black_alpha(90)); + let strip = Rect::from_min_size(r.min, vec2(r.width(), t.tab_h)); + // Move by the strip (tabs, registered later, take precedence over it). + let mv = ui.interact(strip, id.with("move"), Sense::drag()); + self.auto.add(&format!("panel.float.{i}.move"), strip, "Move floating panel"); + let mut nr = r; + if mv.dragged() { + nr = nr.translate(mv.drag_delta()); + } + let g = Group { + path: format!("f{i}"), + rect: r, + content: Rect::from_min_max(pos2(r.min.x, r.min.y + t.tab_h), r.max), + panels: f.panels.clone(), + active: f.active.min(f.panels.len().saturating_sub(1)), + stacked: None, + }; + actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title)); + ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + if let Some(p) = g.panels.get(g.active).copied() { + self.panel_body(ui, p, g.content); + } + // Resize from the corner. + let grip = Rect::from_min_max(r.max - vec2(14.0, 14.0), r.max); + let rs = ui.interact(grip, id.with("resize"), Sense::drag()); + self.auto.add(&format!("panel.float.{i}.resize"), grip, "Resize floating panel"); + for k in 0..3 { + let o = 4.0 + k as f32 * 4.0; + ui.painter().line_segment([pos2(r.max.x - o, r.max.y - 2.0), pos2(r.max.x - 2.0, r.max.y - o)], Stroke::new(1.0, t.text_faint)); + } + if rs.hovered() || rs.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + if rs.dragged() { + nr = Rect::from_min_size(nr.min, (nr.size() + rs.drag_delta()).max(vec2(160.0, 100.0))); + } + if nr != r + && let Some(fl) = self.ui.floating.get_mut(i) + { + fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()]; + } + group = Some(g); + }); + group + } + + /// A tab being dragged: highlight the drop zone under the pointer; on release, dock (or + /// float with Cmd/Ctrl or outside every group). + fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group]) { + let Some(p) = ctx.data(|d| d.get_temp::(drag_id())) else { return }; + let t = self.tokens; + let Some(pos) = ctx.pointer_latest_pos() else { return }; + // Floating windows are on top; then the docked groups. + let target = floats.iter().map(|g| (g, true)).chain(groups.iter().map(|g| (g, false))).find(|(g, _)| g.rect.contains(pos)); + let float_drop = ctx.input(|i| i.modifiers.command); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let plan: Option<(PanelKind, Zone)> = match target { + Some((g, is_float)) if !float_drop => { + let anchor = g.panels.get(g.active).copied().unwrap_or(p); + let zone = if is_float { Zone::Center } else { dock::drop_zone(g.rect, t.tab_h, pos) }; + let self_only = anchor == p && g.panels.len() == 1; + if self_only { + None + } else { + let anchor = if anchor == p { g.panels.iter().copied().find(|x| *x != p).unwrap_or(p) } else { anchor }; + let hr = dock::zone_rect(g.rect, zone).shrink(2.0); + painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25)); + painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + // Outline the whole group for the side zones, like AE's drop-zone frame. + if zone != Zone::Center { + painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside); + } + Some((anchor, zone)) + } + } + _ => None, + }; + // Ghost of the dragged tab. + let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); + painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); + painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ctx.request_repaint(); + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(drag_id())); + let ok = match plan { + Some((anchor, zone)) => self.edit_layout(|l| l.dock(p, anchor, zone)), + None if float_drop || target.is_none() => { + let r = [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0]; + self.edit_layout(|l| l.float(p, r)) + } + None => false, + }; + if ok { + self.ui.focused = p; + self.show_panel(p); + } + } + } +} + +/// A new floating group's default rect near the centre of `screen`. +pub fn default_float_rect(screen: Rect) -> [f32; 4] { + let c = screen.center(); + [c.x - 210.0, c.y - 160.0, 420.0, 320.0] +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ffbf41f5..38dec3c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod audio; pub mod automation; pub mod control; pub mod dock; +pub mod dock_ui; pub mod frames; pub mod header; pub mod icons; @@ -65,6 +66,10 @@ pub enum Dialog { TrackApply, /// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`). Recovery, + /// Composition/Layer Marker (double-click a marker). + Marker, + /// Layer ▸ Pre-compose. + Precompose, } /// Host hooks provided by the native app (file pickers etc.). @@ -140,6 +145,9 @@ pub struct EffectcraftApp { applied_prefs: Option, /// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`). pub recovery: Option, + /// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one + /// under the pointer. + pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>, } impl EffectcraftApp { @@ -178,6 +186,7 @@ impl EffectcraftApp { last_reveal: None, applied_prefs: None, recovery: None, + dock_rects: vec![], } .with_ui_commands() } @@ -243,6 +252,8 @@ impl EffectcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name)); + self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_default(); + self.ui.maximized = None; } /// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace). @@ -253,6 +264,14 @@ impl EffectcraftApp { } pub fn show_panel(&mut self, p: PanelKind) { + if let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p)) { + f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0); + self.ui.focused = p; + return; + } + if self.ui.maximized.is_some_and(|m| m != p) { + self.ui.maximized = None; + } if !self.ui.dock.contains(p) { let near = match p { PanelKind::Layer | PanelKind::Flowchart => PanelKind::Composition, @@ -660,6 +679,7 @@ impl EffectcraftApp { header::show(self, ui, header); let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0)); self.dock_area(ui, body); + panels::precomp::mini_flowchart(self, &ctx); panels::dialogs::show(self, &ctx); self.draw_toast(ui, full); } @@ -694,61 +714,6 @@ impl EffectcraftApp { } } } - - /// Tab labels that name what the panel shows, as After Effects does: "Composition Intro", - /// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp. - fn tab_titles(&self) -> Vec<(PanelKind, String)> { - let mut out = Vec::new(); - let Some(comp) = self.session.active_comp() else { return out }; - let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); - out.push((PanelKind::Composition, format!("Composition {cname}"))); - out.push((PanelKind::Timeline, cname)); - if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { - out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); - out.push((PanelKind::Properties, format!("Properties: {}", l.name))); - } - out - } - - fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { - let t = self.tokens; - let mut dock = std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }); - let mut groups = Vec::new(); - dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); - let mut actions = Vec::new(); - let titles = self.tab_titles(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); - for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); - } - self.ui.dock = dock; - for g in &groups { - let Some(p) = g.panels.get(g.active).copied() else { continue }; - if g.content.height() < 2.0 { - continue; // a collapsed stacked panel: header only - } - self.auto.add(&format!("panel.{}", p.id()), g.content, p.title()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id()))); - child.set_clip_rect(g.content.intersect(ui.clip_rect())); - panels::show(self, &mut child, p, g.content); - } - for a in actions { - match a { - dock::DockAction::Activate(p) => { - self.ui.dock.activate(p); - } - dock::DockAction::ToggleStacked(p) => { - self.ui.dock.toggle_stacked(p); - } - dock::DockAction::Focus(p) => self.ui.focused = p, - dock::DockAction::Close(p) => self.ui.dock.close(p), - dock::DockAction::PanelMenu(p, pos) => { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos))); - } - } - } - panels::panel_menu_popup(self, ui); - } } impl EffectcraftApp { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 595538e8..da5eaec2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -78,6 +78,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.compSettings", "Composition Settings...", [], None), uic!("app.solidSettings", "New Solid...", [], None), uic!("app.home", "Home", [], None), + uic!("markers.dialog", "Marker Settings...", [], None), + uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), + uic!("window.dockPanel", "Dock Panel", [], None), + uic!("window.floatPanel", "Undock Panel", [], None), + uic!("window.closePanel", "Close Panel", [], None), ]; pub fn panel_command_id(p: PanelKind) -> String { @@ -164,6 +169,58 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V reveal(app, k, now); return Ok(Value::Null); } + // Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}. + if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) { + let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`"))); + match op { + "maximizePanel" => { + let p = match panel_of("panel") { + Some(p) => p?, + None => app.panel_at(ctx.pointer_hover_pos()), + }; + app.toggle_maximize(p); + return Ok(json!({"maximized": app.ui.maximized})); + } + "closePanel" => { + let p = panel_of("panel").ok_or("closePanel: need `panel`")??; + app.close_panel(p); + return Ok(Value::Null); + } + "floatPanel" => { + let p = panel_of("panel").ok_or("floatPanel: need `panel`")??; + let r = params.get("rect").and_then(Value::as_array).map(|a| { + let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32; + [g(0), g(1), g(2), g(3)] + }); + let r = r.unwrap_or_else(|| crate::dock_ui::default_float_rect(ctx.content_rect())); + if !app.edit_layout(|l| l.float(p, r)) { + return Err(format!("can't undock {}", p.title())); + } + return Ok(Value::Null); + } + _ => { + let p = panel_of("panel").ok_or("dockPanel: need `panel`")??; + let anchor = panel_of("anchor").ok_or("dockPanel: need `anchor` (a docked panel)")??; + let zone = params + .get("zone") + .and_then(Value::as_str) + .map_or(Some(crate::dock::Zone::Center), crate::dock::Zone::from_name) + .ok_or("zone: center|left|right|top|bottom")?; + if !app.edit_layout(|l| l.dock(p, anchor, zone)) { + return Err(format!("can't dock {} at {} ({zone:?})", p.title(), anchor.title())); + } + app.show_panel(p); + return Ok(Value::Null); + } + } + } + if id == "markers.dialog" { + // The Composition/Layer Marker dialog (double-click a marker): {layer?, index}. + let index = params.get("index").and_then(Value::as_u64).ok_or("markers.dialog: need `index`")? as usize; + let layer = params.get("layer").and_then(Value::as_u64); + crate::panels::markers_ui::open_dialog(app, crate::panels::markers_ui::MarkerRef { layer, index })?; + return Ok(json!({"dialog": id})); + } if id == "view.res.auto" { app.ui.viewer.res = Resolution::Auto; return Ok(Value::Null); @@ -254,6 +311,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if let Some(r) = file_dialog(app, id, ¶ms) { return r; } + if id == "layer.precompose" && params.get("name").is_none() { + crate::panels::precomp::open(app, ¶ms)?; + return Ok(json!({"dialog": id})); + } if crate::panels::dialogs::open_form(app, id, ¶ms) { return Ok(json!({"dialog": id})); } @@ -498,6 +559,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu "window.saveWorkspace" => { let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); + app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); json!({"workspace": name}) } "window.saveWorkspaceAs" => { @@ -512,6 +574,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu return Ok(Value::Null); }; app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); + app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); app.ui.workspace = name.clone(); json!({"workspace": name}) } @@ -533,6 +596,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; let builtin = crate::dock::WORKSPACES.contains(&name.as_str()); if p.get("delete").and_then(Value::as_bool) == Some(true) { + app.ui.saved_floating.remove(&name); if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } @@ -547,6 +611,9 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?; app.ui.saved_workspaces.insert(new.to_string(), dock); + if let Some(f) = app.ui.saved_floating.remove(&name) { + app.ui.saved_floating.insert(new.to_string(), f); + } if app.ui.workspace == name { app.ui.workspace = new.to_string(); } @@ -569,10 +636,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } Value::Null } - "comp.flowchart" | "comp.miniFlowchart" => { + "comp.flowchart" => { app.show_panel(PanelKind::Flowchart); Value::Null } + "comp.miniFlowchart" => { + let at = ctx.input(|i| i.pointer.hover_pos()).unwrap_or_else(|| ctx.content_rect().center()); + app.ui.mini_flowchart = Some([at.x, at.y]); + Value::Null + } "track.editTargetDialog" => { crate::panels::tracker::open_target(app)?; Value::Null @@ -915,6 +987,10 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { if app.dialog.is_some() { continue; } + // Enter in the Project panel renames the selected item (handled by the panel). + if key == egui::Key::Enter && !mods.any() && app.ui.focused == PanelKind::Project { + continue; + } // Delete/Backspace clears selection. if matches!(key, egui::Key::Delete | egui::Key::Backspace) && !mods.any() { let _ = invoke(app, ctx, "edit.clear", json!({})); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs new file mode 100644 index 00000000..aa4b6368 --- /dev/null +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -0,0 +1,296 @@ +//! Wiggler, Smoother and Motion Sketch panels (Window menu). Their settings live in +//! [`crate::state::AnimToolsState`] (serde, so agents can read and set them); Apply runs the +//! engine commands `keys.wiggle`, `keys.smooth` and `motion.sketch`. +//! +//! Motion Sketch capture is an overlay on the Composition viewer ([`sketch_overlay`]): after +//! Start Capture, pressing in the viewer records the pointer path in real time while the comp +//! plays; releasing turns it into Position keys. + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +const ROW: f32 = 26.0; + +fn label(p: &egui::Painter, x: f32, y: f32, s: &str, t: &Tokens) { + p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, s, Tokens::ui(12.0), t.text_dim); +} + +/// A dropdown with options; returns the new index when changed. +fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option { + let t = app.tokens; + let id = egui::Id::new(("animtool-dd", auto)); + if widgets::dropdown(ui, r, options.get(cur).copied().unwrap_or(""), &t, id).clicked() { + widgets::open_popup(ui, id); + } + app.auto.add(auto, r, options.get(cur).copied().unwrap_or("")); + let opts: Vec = options.iter().map(|s| s.to_string()).collect(); + widgets::popup_menu(ui, id, r.left_bottom(), &opts, Some(cur)).filter(|i| *i != cur) +} + +fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 { + let t = app.tokens; + let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("animtool-n", auto)), v, speed, range, dec, suffix, &t); + app.auto.add(auto, r, &format!("{v}")); + nv.unwrap_or(v) +} + +fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { + let t = app.tokens; + let r = Rect::from_min_size(at, vec2(16.0, 16.0)); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked(); + ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); + app.auto.add(auto, r, text); + if clicked { !on } else { on } +} + +fn status_line(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, y: f32, need: usize) { + let t = app.tokens; + let n = app.session.state.selected_keys.len(); + let msg = if n >= need { format!("{n} keyframes selected") } else { format!("Select at least {need} keyframes of a property") }; + ui.painter().with_clip_rect(rect).text(pos2(rect.min.x + 12.0, y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_faint); +} + +fn apply(app: &mut EffectcraftApp, cmd: &str, params: serde_json::Value) { + match app.session.execute(cmd, params) { + Ok(r) => app.ui.status = format!("{}: {} → {} keyframes", if cmd == "keys.wiggle" { "Wiggler" } else { "Smoother" }, r["before"], r["after"]), + Err(e) => app.ui.status = e.to_string(), + } +} + +/// Wiggler panel: Apply To, Noise Type, Dimension, Frequency, Magnitude. +pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + let x0 = rect.min.x + 12.0; + let xv = x0 + 96.0; + let w = (rect.max.x - xv - 12.0).clamp(80.0, 200.0); + let mut y = rect.min.y + 10.0; + let mut st = app.ui.anim_tools.clone(); + label(&p, x0, y, "Apply To:", &t); + if let Some(i) = + choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "wiggler.applyTo", &["Spatial Path", "Temporal Path"], usize::from(!st.wiggle_spatial)) + { + st.wiggle_spatial = i == 0; + } + y += ROW; + label(&p, x0, y, "Noise Type:", &t); + if let Some(i) = choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "wiggler.noise", &["Smooth", "Jagged"], usize::from(!st.wiggle_smooth)) { + st.wiggle_smooth = i == 0; + } + y += ROW; + label(&p, x0, y, "Dimension:", &t); + let dims = ["X", "Y", "Z", "All the Same", "All Independently"]; + let cur = match st.wiggle_dims.as_str() { + "one" => st.wiggle_dim.min(2), + "same" => 3, + _ => 4, + }; + if let Some(i) = choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "wiggler.dimension", &dims, cur) { + match i { + 0..=2 => { + st.wiggle_dims = "one".into(); + st.wiggle_dim = i; + } + 3 => st.wiggle_dims = "same".into(), + _ => st.wiggle_dims = "independent".into(), + } + } + y += ROW + 4.0; + label(&p, x0, y, "Frequency:", &t); + st.wiggle_frequency = number(app, ui, pos2(xv, y), "wiggler.frequency", st.wiggle_frequency, 0.1, (0.1, 100.0), 1, " per second"); + y += ROW; + label(&p, x0, y, "Magnitude:", &t); + st.wiggle_magnitude = number(app, ui, pos2(xv, y), "wiggler.magnitude", st.wiggle_magnitude, 0.5, (0.0, 100_000.0), 1, ""); + y += ROW + 8.0; + app.ui.anim_tools = st.clone(); + let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); + let enabled = app.session.state.selected_keys.len() >= 2; + if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("wg-apply")).clicked() { + let params = json!({ + "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, + "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, + "dimensions": st.wiggle_dims, + "dimension": st.wiggle_dim, + "frequency": st.wiggle_frequency, + "magnitude": st.wiggle_magnitude, + "seed": app.session.revision, + }); + apply(app, "keys.wiggle", params); + } + app.auto.add("wiggler.apply", b, "Apply"); + status_line(app, ui, rect, b.max.y + 16.0, 2); +} + +/// Smoother panel: Apply To (from the selected property), Tolerance. +pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + let x0 = rect.min.x + 12.0; + let xv = x0 + 96.0; + let mut y = rect.min.y + 10.0; + let spatial = app.session.state.selected_keys.first().and_then(|k| { + let c = app.session.active_comp()?; + Some(c.layer(k.layer)?.props.find(k.prop)?.spatial) + }); + label(&p, x0, y, "Apply To:", &t); + let txt = match spatial { + Some(true) => "Spatial Path", + Some(false) => "Temporal Graph", + None => "—", + }; + p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, txt, Tokens::ui(12.0), t.text); + y += ROW; + label(&p, x0, y, "Tolerance:", &t); + let tol = number(app, ui, pos2(xv, y), "smoother.tolerance", app.ui.anim_tools.smooth_tolerance, 0.05, (0.0, 10_000.0), 2, ""); + app.ui.anim_tools.smooth_tolerance = tol; + y += ROW + 8.0; + let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); + let enabled = app.session.state.selected_keys.len() >= 3; + if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("sm-apply")).clicked() { + apply(app, "keys.smooth", json!({"tolerance": tol})); + } + app.auto.add("smoother.apply", b, "Apply"); + status_line(app, ui, rect, b.max.y + 16.0, 3); +} + +/// Motion Sketch panel: Capture speed, Smoothing, Show Wireframe / Background, Start Capture. +pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + let x0 = rect.min.x + 12.0; + let xv = x0 + 110.0; + let mut y = rect.min.y + 10.0; + let mut st = app.ui.anim_tools.clone(); + label(&p, x0, y, "Capture speed at:", &t); + st.sketch_speed = number(app, ui, pos2(xv, y), "motionSketch.captureSpeed", st.sketch_speed, 1.0, (1.0, 10_000.0), 0, " %"); + y += ROW; + label(&p, x0, y, "Smoothing:", &t); + st.sketch_smoothing = number(app, ui, pos2(xv, y), "motionSketch.smoothing", st.sketch_smoothing, 0.1, (0.0, 100.0), 1, ""); + y += ROW + 2.0; + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Show:", Tokens::ui(12.0), t.text_dim); + y += ROW - 6.0; + st.sketch_wireframe = checkbox(app, ui, pos2(x0 + 8.0, y), "motionSketch.wireframe", "Wireframe", st.sketch_wireframe); + y += ROW - 4.0; + st.sketch_background = checkbox(app, ui, pos2(x0 + 8.0, y), "motionSketch.background", "Background", st.sketch_background); + y += ROW; + let Some(c) = app.session.active_comp() else { + app.ui.anim_tools = st; + return; + }; + let fr = c.frame_rate; + let (s, e) = (app.session.time(), c.work_area.1); + let tc = |x| crate::panels::timecode(&app.session, c, x); + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, format!("Start: {} Duration: {}", tc(s), tc(e - s)), Tokens::ui(11.0), t.text_faint); + let _ = fr; + y += ROW; + let b = Rect::from_min_size(pos2(x0, y), vec2(120.0, 24.0)); + let armed = st.sketch_armed; + let has_layer = !app.session.state.selected_layers.is_empty(); + if widgets::text_button(ui, b, if armed { "Cancel Capture" } else { "Start Capture" }, has_layer && !armed, &t, egui::Id::new("ms-start")).clicked() { + if !has_layer { + app.ui.status = "Select a layer to sketch its motion".into(); + } else { + st.sketch_armed = !armed; + if st.sketch_armed { + app.ui.status = "Motion Sketch: drag in the Composition panel to record".into(); + } + } + } + app.auto.add("motionSketch.start", b, "Start Capture"); + app.ui.anim_tools = st; +} + +/// A capture in progress: recording start (ui seconds), comp start time, points (s, x, y). +#[derive(Clone, Debug)] +struct Capture { + t0: f64, + start: effectcraft_engine::time::Tick, + points: Vec<[f64; 3]>, +} + +fn capture_id() -> egui::Id { + egui::Id::new("motion-sketch-capture") +} + +/// The Motion Sketch overlay on the Composition viewer (`area`): while armed, a press starts +/// recording, the comp time advances in real time (scaled by the capture speed), and the release +/// creates the keys. Draws the recorded path and, with Wireframe, the layer's box at the pointer. +pub fn sketch_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui) { + if !app.ui.anim_tools.sketch_armed { + return; + } + let ctx = ui.ctx().clone(); + let Some(map) = ctx.data(|d| d.get_temp::(egui::Id::new("viewer-map"))) else { return }; + let area = map.area; + let resp = ui.interact(area, egui::Id::new("motion-sketch-overlay"), Sense::drag()); + app.auto.add("motionSketch.overlay", area, "Motion Sketch capture area"); + let now = ctx.input(|i| i.time); + let painter = ui.painter().with_clip_rect(area); + if !app.ui.anim_tools.sketch_background { + painter.rect_filled(Rect::from_min_size(map.origin, vec2(map.comp[0] * map.zoom, map.comp[1] * map.zoom)), 0.0, Color32::from_black_alpha(200)); + } + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let mut cap: Option = ctx.data(|d| d.get_temp(capture_id())); + if resp.drag_started() + && let Some(pt) = resp.interact_pointer_pos() + { + let q = map.to_comp(pt); + cap = Some(Capture { t0: now, start: app.session.time(), points: vec![[0.0, q[0], q[1]]] }); + } + if let Some(c) = cap.as_mut() { + if let Some(pt) = ctx.input(|i| i.pointer.latest_pos()) { + let q = map.to_comp(pt); + let el = now - c.t0; + if c.points.last().is_none_or(|l| el > l[0]) { + c.points.push([el, q[0], q[1]]); + } + // Play the comp in real time (slowed by the capture speed) while recording. + let speed = app.ui.anim_tools.sketch_speed.max(1.0) / 100.0; + let comp_t = c.start + effectcraft_engine::time::Tick::from_seconds_f64(el * speed); + if let Some(comp) = app.session.active_comp() + && comp_t < comp.duration + { + let ft = comp.frame_rate.snap_nearest(comp_t); + app.session.set_time(ft); + } + } + let pts: Vec = c.points.iter().map(|p| map.to_screen([p[1], p[2]])).collect(); + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.5, Color32::from_rgb(0xff, 0x60, 0x40)))); + if app.ui.anim_tools.sketch_wireframe + && let Some(last) = pts.last() + { + painter.rect_stroke( + Rect::from_center_size(*last, vec2(60.0, 40.0) * map.zoom.max(0.2)), + 0.0, + Stroke::new(1.0, Color32::WHITE), + egui::StrokeKind::Inside, + ); + } + ctx.request_repaint(); + } + if resp.drag_stopped() + && let Some(c) = cap.take() + { + ctx.data_mut(|d| d.remove::(capture_id())); + app.ui.anim_tools.sketch_armed = false; + let st = &app.ui.anim_tools; + let params = json!({"points": c.points, "start": c.start.seconds(), "captureSpeed": st.sketch_speed, "smoothing": st.sketch_smoothing}); + match app.session.execute("motion.sketch", params) { + Ok(r) => app.ui.status = format!("Motion Sketch: {} Position keyframes", r["keys"]), + Err(e) => app.ui.status = e.to_string(), + } + app.session.set_time(c.start); + return; + } + match cap { + Some(c) => { + ctx.data_mut(|d| d.insert_temp(capture_id(), c)); + } + None => { + painter.text(area.center_top() + vec2(0.0, 18.0), Align2::CENTER_CENTER, "Motion Sketch: drag to record", Tokens::ui(12.0), Color32::WHITE); + } + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index bccf319e..5aea865e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -38,6 +38,10 @@ pub struct DialogState { pub track_options: super::tracker::OptionsDraft, pub track_target: super::tracker::TargetDraft, pub track_apply: super::tracker::ApplyDraft, + /// Composition/Layer Marker dialog. + pub marker: super::markers_ui::MarkerDraft, + /// Pre-compose dialog. + pub precompose: super::precomp::PrecomposeDraft, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -117,6 +121,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t), Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t), Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t), + Dialog::Marker => super::markers_ui::dialog(app, ctx, &t), + Dialog::Precompose => super::precomp::dialog(app, ctx, &t), Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t), Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t), } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e0f1a5f2..271cc88d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -290,10 +290,6 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ) } - "layer.precompose" if !has(p, &["name"]) => { - let n = s.project.comps().count() + 1; - ("Pre-compose".into(), vec![Field::text("name", "New composition name", &format!("Pre-comp {n}"))]) - } "file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => ( "New Placeholder".into(), vec![ diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs new file mode 100644 index 00000000..ce57e10f --- /dev/null +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -0,0 +1,413 @@ +//! Markers in the Timeline (composition markers in the ruler, layer markers on layer bars) and +//! the Composition/Layer Marker dialog. +//! +//! Gestures, as in After Effects: drag moves a marker (snapping to the current time, the work +//! area and other markers), Alt-drag sets its duration, Cmd-click (Ctrl-click elsewhere) +//! deletes it, double-click opens the Marker dialog, right-click offers Settings / Convert / +//! Delete. Protected regions (Responsive Design) shade the timeline. Every change is a +//! `markers.*` engine command, so it is undoable and agent-drivable. + +use effectcraft_engine::project::{Comp, Layer, Marker}; +use effectcraft_engine::time::Tick; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; + +use super::timeline::TMap; +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +/// Which marker a gesture or the dialog refers to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MarkerRef { + /// `None` = composition marker. + pub layer: Option, + pub index: usize, +} + +impl MarkerRef { + fn params(&self) -> Value { + match self.layer { + Some(l) => json!({"layer": l, "index": self.index}), + None => json!({"index": self.index}), + } + } + fn auto_id(&self) -> String { + match self.layer { + Some(l) => format!("timeline.layer.{l}.marker.{}", self.index), + None => format!("timeline.marker.{}", self.index), + } + } +} + +/// Marker dialog fields. +#[derive(Clone, Debug, Default)] +pub struct MarkerDraft { + pub target: Option, + pub title: String, + pub time: f64, + pub duration: f64, + pub comment: String, + pub chapter: String, + pub url: String, + pub frame_target: String, + pub cue: bool, + pub cue_navigation: bool, + pub cue_name: String, + pub cue_params: Vec<(String, String)>, + pub protected: bool, + pub label: usize, +} + +/// Open the Composition/Layer Marker dialog for `m`. +pub fn open_dialog(app: &mut EffectcraftApp, m: MarkerRef) -> Result<(), String> { + let mut p = json!({}); + if let Some(l) = m.layer { + p["layer"] = json!(l); + } + let list = app.session.execute("markers.list", p).map_err(|e| e.to_string())?; + let v = list.get(m.index).ok_or("no such marker")?; + let s = |k: &str| v[k].as_str().unwrap_or_default().to_string(); + let cue = &v["cuePoint"]; + app.dialog_state.marker = MarkerDraft { + target: Some(m), + title: if m.layer.is_some() { "Layer Marker".into() } else { "Composition Marker".into() }, + time: v["time"].as_f64().unwrap_or(0.0), + duration: v["duration"].as_f64().unwrap_or(0.0), + comment: s("comment"), + chapter: s("chapter"), + url: s("url"), + frame_target: s("frameTarget"), + cue: !cue.is_null(), + cue_navigation: cue["navigation"].as_bool().unwrap_or(false), + cue_name: cue["name"].as_str().unwrap_or_default().to_string(), + cue_params: cue["params"] + .as_array() + .map(|a| a.iter().map(|kv| (kv[0].as_str().unwrap_or_default().to_string(), kv[1].as_str().unwrap_or_default().to_string())).collect()) + .unwrap_or_default(), + protected: v["protected"].as_bool().unwrap_or(false), + label: effectcraft_engine::color::Label::from_name(&s("label")) + .and_then(|l| effectcraft_engine::color::Label::ALL.iter().position(|x| *x == l)) + .unwrap_or(0), + }; + app.dialog = Some(Dialog::Marker); + Ok(()) +} + +/// The command parameters of the dialog's current fields. +pub fn draft_params(d: &MarkerDraft) -> Value { + let mut p = d.target.map(|t| t.params()).unwrap_or_else(|| json!({})); + p["time"] = json!(d.time); + p["duration"] = json!(d.duration); + p["comment"] = json!(d.comment); + p["chapter"] = json!(d.chapter); + p["url"] = json!(d.url); + p["frameTarget"] = json!(d.frame_target); + p["protected"] = json!(d.protected); + p["label"] = json!(d.label); + p["cuePoint"] = if d.cue { + json!({"name": d.cue_name, "navigation": d.cue_navigation, "params": d.cue_params.iter().filter(|(k, _)| !k.is_empty()).map(|(k, v)| json!([k, v])).collect::>()}) + } else { + Value::Null + }; + p +} + +fn text_field(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut String, w: f32) { + let r = ui.add(egui::TextEdit::singleline(v).desired_width(w)); + app.auto.add(id, r.rect, id); +} + +/// The Composition/Layer Marker dialog. +pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.marker.clone(); + let (mut ok, mut cancel, mut delete) = (false, false, false); + let title = d.title.clone(); + super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { + egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Time:"); + let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + app.auto.add("dialog.marker.time", r.rect, "Time"); + ui.end_row(); + ui.label("Duration:"); + let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + app.auto.add("dialog.marker.duration", r.rect, "Duration"); + ui.end_row(); + ui.label("Comment:"); + let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0)); + app.auto.add("dialog.marker.comment", r.rect, "Comment"); + ui.end_row(); + ui.label("Chapter:"); + text_field(ui, app, "dialog.marker.chapter", &mut d.chapter, 320.0); + ui.end_row(); + ui.label("URL:"); + text_field(ui, app, "dialog.marker.url", &mut d.url, 320.0); + ui.end_row(); + ui.label("Frame Target:"); + text_field(ui, app, "dialog.marker.frameTarget", &mut d.frame_target, 320.0); + ui.end_row(); + }); + ui.add_space(8.0); + let r = ui.checkbox(&mut d.cue, "Cue Point"); + app.auto.add("dialog.marker.cue", r.rect, "Cue Point"); + ui.add_enabled_ui(d.cue, |ui| { + ui.horizontal(|ui| { + let r = ui.radio_value(&mut d.cue_navigation, false, "Event"); + app.auto.add("dialog.marker.cue.event", r.rect, "Event"); + let r = ui.radio_value(&mut d.cue_navigation, true, "Navigation"); + app.auto.add("dialog.marker.cue.navigation", r.rect, "Navigation"); + ui.label("Name:"); + text_field(ui, app, "dialog.marker.cue.name", &mut d.cue_name, 160.0); + }); + let mut remove = None; + for (i, (k, v)) in d.cue_params.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.label("Parameter Name:"); + text_field(ui, app, &format!("dialog.marker.cue.param.{i}.name"), k, 110.0); + ui.label("Value:"); + text_field(ui, app, &format!("dialog.marker.cue.param.{i}.value"), v, 110.0); + let r = ui.small_button("−"); + app.auto.add(&format!("dialog.marker.cue.param.{i}.remove"), r.rect, "Remove"); + if r.clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + d.cue_params.remove(i); + } + let r = ui.small_button("+ Parameter"); + app.auto.add("dialog.marker.cue.addParam", r.rect, "Add Parameter"); + if r.clicked() { + d.cue_params.push((String::new(), String::new())); + } + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.protected, "Protected Region"); + app.auto.add("dialog.marker.protected", r.rect, "Protected Region"); + ui.add_space(20.0); + ui.label("Label:"); + let labels = effectcraft_engine::color::Label::ALL; + let cur = labels.get(d.label).copied().unwrap_or_default(); + let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| { + for (i, l) in labels.iter().enumerate() { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, app.tokens.label(*l)); + ui.selectable_value(&mut d.label, i, app.session.prefs.label_name(*l)); + }); + } + }); + app.auto.add("dialog.marker.label", r.response.rect, "Label"); + }); + ui.add_space(14.0); + ui.horizontal(|ui| { + let r = ui.button("Delete"); + app.auto.add("dialog.marker.delete", r.rect, "Delete"); + delete = r.clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(app.tokens.accent)); + app.auto.add("dialog.marker.ok", r.rect, "OK"); + ok = r.clicked(); + let r = ui.button("Cancel"); + app.auto.add("dialog.marker.cancel", r.rect, "Cancel"); + cancel = r.clicked(); + }); + }); + }); + app.dialog_state.marker = d.clone(); + if ok { + if let Err(e) = app.session.execute("markers.set", draft_params(&d)) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if delete && let Some(m) = d.target { + if let Err(e) = app.session.execute("markers.delete", m.params()) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +// ---------------------------------------------------------------- timeline drawing + gestures + +fn drag_state_id() -> egui::Id { + egui::Id::new("marker-drag") +} + +/// Snap `t` (comp seconds) to the CTI, the work area and other marker times within 8 px. +fn snap(app: &EffectcraftApp, comp: &Comp, tm: TMap, t: f64, skip: MarkerRef) -> f64 { + let fr = comp.frame_rate; + let mut t = fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0))).seconds(); + if !app.session.state.snapping { + return t; + } + let mut cands = vec![app.session.time().seconds(), comp.work_area.0.seconds(), comp.work_area.1.seconds()]; + cands.extend(comp.markers.iter().enumerate().filter(|(i, _)| skip != MarkerRef { layer: None, index: *i }).map(|(_, m)| m.time.seconds())); + let px = |a: f64| (a - t).abs() * tm.pps; + if let Some(best) = cands.into_iter().filter(|c| px(*c) < 8.0).min_by(|a, b| px(*a).total_cmp(&px(*b))) { + t = best; + } + t +} + +/// Draw one marker (triangle + duration bar + comment) at `x` in a strip `[y0, y1]` and handle +/// its gestures. `comp_t` is the marker's comp time. +#[allow(clippy::too_many_arguments)] +fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, tm: TMap, m: &Marker, comp_t: f64, r: MarkerRef, y0: f32, y1: f32) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let x = tm.x(comp_t); + let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) }; + let h = (y1 - y0).min(10.0); + let ym = y0 + h * 0.6; + if m.duration > Tick(0) { + let xe = tm.x(comp_t + m.duration.seconds()); + let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0)); + p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 })); + if m.protected { + let mut hx = bar.min.x; + while hx < bar.max.x { + p.line_segment([pos2(hx, bar.max.y), pos2((hx + bar.height()).min(bar.max.x), bar.min.y)], Stroke::new(1.0, col)); + hx += 5.0; + } + } + p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col)); + } + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)], col, Stroke::NONE)); + if !m.comment.is_empty() { + p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text); + } + let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1)); + let id = egui::Id::new(("marker", r.layer, r.index)); + let resp = ui.interact(hit, id, Sense::click_and_drag()); + app.auto.add(&r.auto_id(), hit, if m.comment.is_empty() { "marker" } else { &m.comment }); + let tip = if m.comment.is_empty() { "Marker".to_string() } else { m.comment.clone() }; + let resp = resp.on_hover_text(tip); + let mods = ui.input(|i| i.modifiers); + if resp.double_clicked() { + if let Err(e) = open_dialog(app, r) { + app.ui.status = e; + } + return; + } + if resp.clicked() && (if cfg!(target_os = "macos") { mods.mac_cmd || mods.command } else { mods.ctrl }) { + let _ = app.session.execute("markers.delete", r.params()); + return; + } + if resp.drag_started() { + ctx.data_mut(|d| d.insert_temp(drag_state_id(), (r.layer, r.index, mods.alt))); + } + if resp.dragged() + && let Some(pt) = resp.interact_pointer_pos() + && let Some((layer, index, alt)) = ctx.data(|d| d.get_temp::<(Option, usize, bool)>(drag_state_id())) + { + let cur = MarkerRef { layer, index }; + let merge = format!("marker-drag-{layer:?}"); + let mut params = cur.params(); + params["merge"] = json!(merge); + let tt = snap(app, comp, tm, tm.t(pt.x), cur); + if alt { + params["duration"] = json!((tt - comp_t).max(0.0)); + } else { + params["time"] = json!(tt); + } + if let Ok(v) = app.session.execute("markers.set", params) + && let Some(ni) = v["index"].as_u64() + { + ctx.data_mut(|d| d.insert_temp(drag_state_id(), (layer, ni as usize, alt))); + } + } + if resp.drag_stopped() { + ctx.data_mut(|d| d.remove::<(Option, usize, bool)>(drag_state_id())); + app.session.history.merge_key = None; + } + resp.context_menu(|ui| { + let run = |ui: &mut egui::Ui, label: &str, cmd: &str, params: Value, app: &mut EffectcraftApp| { + if ui.button(label).clicked() { + if cmd == "dialog" { + let _ = open_dialog(app, r); + } else if let Err(e) = app.session.execute(cmd, params) { + app.ui.status = e.to_string(); + } + ui.close(); + } + }; + run(ui, "Settings…", "dialog", Value::Null, app); + if r.layer.is_some() { + run(ui, "Convert to Composition Marker", "markers.convert", r.params(), app); + run(ui, "Lock Markers", "layer.markersLock", json!({"layers": [r.layer]}), app); + } else { + run(ui, "Convert to Layer Marker (selected layers)", "markers.convert", r.params(), app); + } + ui.separator(); + run(ui, "Delete This Marker", "markers.delete", r.params(), app); + if let Some(l) = r.layer { + run(ui, "Delete All Markers", "layer.deleteAllMarkers", json!({"layers": [l]}), app); + } + }); +} + +/// Composition markers in the ruler strip `[y0, y1]`, protected regions over `rows`. +pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, strip: Rect, rows: Rect) { + let p = ui.painter().with_clip_rect(strip); + let rp = ui.painter().with_clip_rect(rows); + for m in comp.markers.iter().filter(|m| m.protected && m.duration > Tick(0)) { + let (x0, x1) = (tm.x(m.time.seconds()), tm.x((m.time + m.duration).seconds())); + rp.rect_filled(Rect::from_min_max(pos2(x0, rows.min.y), pos2(x1, rows.max.y)), 0.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 22)); + rp.line_segment([pos2(x0, rows.min.y), pos2(x0, rows.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 90))); + rp.line_segment([pos2(x1, rows.min.y), pos2(x1, rows.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 90))); + } + for (i, m) in comp.markers.iter().enumerate() { + marker(app, ui, &p, comp, tm, m, m.time.seconds(), MarkerRef { layer: None, index: i }, strip.min.y, strip.max.y); + } +} + +/// Layer markers on a layer's row `r`. +pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) { + let p = ui.painter().with_clip_rect(clip); + for (i, m) in layer.markers.iter().enumerate() { + marker(app, ui, &p, comp, tm, m, layer.comp_time(m.time).seconds(), MarkerRef { layer: Some(layer.id.0), index: i }, r.min.y + 3.0, r.max.y - 3.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_dialog_reads_and_writes_every_field() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "hello", "cuePoint": {"name": "c", "params": [["a", "1"]]}, "label": "Blue"})) + .unwrap(); + let mut app = EffectcraftApp::new(s); + open_dialog(&mut app, MarkerRef { layer: None, index: 0 }).unwrap(); + assert_eq!(app.dialog, Some(Dialog::Marker)); + let d = &mut app.dialog_state.marker; + assert_eq!((d.time, d.comment.as_str(), d.cue, d.cue_name.as_str()), (1.0, "hello", true, "c")); + assert_eq!(d.cue_params, vec![("a".to_string(), "1".to_string())]); + assert_eq!(effectcraft_engine::color::Label::ALL[d.label].name(), "Blue"); + d.duration = 0.5; + d.chapter = "Ch".into(); + d.url = "https://getartcraft.com".into(); + d.frame_target = "_self".into(); + d.protected = true; + d.cue = false; + let p = draft_params(d); + app.session.execute("markers.set", p).unwrap(); + let m = &app.session.execute("markers.list", json!({})).unwrap()[0]; + assert_eq!(m["duration"], 0.5); + assert_eq!(m["chapter"], "Ch"); + assert_eq!(m["frameTarget"], "_self"); + assert_eq!(m["protected"], true); + assert!(m["cuePoint"].is_null()); + assert_eq!(m["label"], "Blue"); + app.session.undo(); + assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], ""); + } +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 056e6acd..b2b8dbfe 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -1,4 +1,4 @@ -//! Smaller panels: Preview, Audio, History, Markers, Wiggler, the Home screen and placeholders. +//! Smaller panels: Preview, Audio, History, Markers, the Home screen and placeholders. use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; @@ -224,45 +224,6 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -/// Wiggler: add random keyframes between two selected keys (applied as a wiggle expression). -pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); - let freq_id = egui::Id::new("wiggler-freq"); - let mag_id = egui::Id::new("wiggler-mag"); - let mut freq: f64 = ui.data(|d| d.get_temp(freq_id).unwrap_or(5.0)); - let mut mag: f64 = ui.data(|d| d.get_temp(mag_id).unwrap_or(30.0)); - let x0 = rect.min.x + 12.0; - let mut y = rect.min.y + 14.0; - p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Frequency:", Tokens::ui(12.0), t.text_dim); - let (_, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 110.0, y), egui::Id::new("wg-f"), freq, 0.1, (0.1, 100.0), 1, " per second", &t); - if let Some(v) = nv { - freq = v; - } - y += 26.0; - p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Magnitude:", Tokens::ui(12.0), t.text_dim); - let (_, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 110.0, y), egui::Id::new("wg-m"), mag, 0.5, (0.0, 10000.0), 1, "", &t); - if let Some(v) = nv { - mag = v; - } - ui.data_mut(|d| { - d.insert_temp(freq_id, freq); - d.insert_temp(mag_id, mag); - }); - y += 34.0; - let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); - if widgets::text_button(ui, b, "Apply", true, &t, egui::Id::new("wg-apply")).clicked() { - let props = app.session.state.selected_props.clone(); - if props.is_empty() { - app.ui.status = "Select a property in the Timeline first".into(); - } - for (l, u) in props { - let _ = app.session.execute("prop.setExpression", json!({"layer": l.0, "prop": u, "expression": format!("wiggle({freq}, {mag})")})); - } - } - app.auto.add("wiggler.apply", b, "Apply"); -} - /// Home screen: new/open, recent, and community links. pub fn start_screen(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2c2f6919..39cf4c5c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Panel bodies. +pub mod anim_tools; pub mod comp_settings; pub mod dialogs; pub mod dialogs_3d; @@ -12,8 +13,10 @@ pub mod graph_tools; pub mod info; pub mod key_dialogs; pub mod layer_panel; +pub mod markers_ui; pub mod misc; pub mod paint_panels; +pub mod precomp; pub mod project; pub mod properties; pub mod puppet_tool; @@ -78,7 +81,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Audio => misc::audio(app, ui, rect), PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect), - PanelKind::Wiggler => misc::wiggler(app, ui, rect), + PanelKind::Wiggler => anim_tools::wiggler(app, ui, rect), + PanelKind::Smoother => anim_tools::smoother(app, ui, rect), + PanelKind::MotionSketch => anim_tools::motion_sketch(app, ui, rect), PanelKind::RenderQueue => render_queue::show(app, ui, rect), PanelKind::Tracker => tracker::show(app, ui, rect), PanelKind::Layer => layer_panel::show(app, ui, rect), @@ -97,11 +102,17 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); if ui.button("Close Panel").clicked() { - app.ui.dock.close(panel); + app.close_panel(panel); close = true; } - if ui.button("Maximize Panel").clicked() { - app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![panel], active: 0 }; + let floating = app.ui.floating.iter().any(|f| f.panels.contains(&panel)); + if !floating && ui.button("Undock Panel").clicked() { + let r = crate::dock_ui::default_float_rect(ui.ctx().content_rect()); + app.edit_layout(|l| l.float(panel, r)); + close = true; + } + if !floating && ui.button(if app.ui.maximized == Some(panel) { "Restore Panel Size" } else { "Maximize Panel" }).clicked() { + app.toggle_maximize(panel); close = true; } ui.separator(); diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs new file mode 100644 index 00000000..1a59fc9f --- /dev/null +++ b/crates/ui-egui/src/panels/precomp.rs @@ -0,0 +1,355 @@ +//! Precomposition workflow: the Pre-compose dialog (Leave / Move all attributes, Adjust +//! composition duration, Open New Composition), the Composition Navigator bar above the viewer +//! (the flow of nested comps, click to open) and the Composition Mini-Flowchart popup (Tab). + +use effectcraft_engine::project::{ItemId, LayerSource, Project}; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +// ---------------------------------------------------------------- flow graph + +/// Comps that contain a layer whose source is `cid` (upstream), in project order. +pub fn upstream(project: &Project, cid: ItemId) -> Vec { + project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source == LayerSource::Comp { item: cid })).map(|(id, _)| *id).collect() +} + +/// Comps used as layer sources in `cid` (downstream), first use first, without repeats. +pub fn downstream(project: &Project, cid: ItemId) -> Vec { + let mut out = vec![]; + if let Some(c) = project.comp(cid) { + for l in &c.layers { + if let LayerSource::Comp { item } = l.source + && !out.contains(&item) + && project.comp(item).is_some() + { + out.push(item); + } + } + } + out +} + +/// The navigator chain ending at `cid`: walk upstream, preferring comps opened most recently +/// (`open` is the open-comps order), stopping at a root or a cycle. +pub fn chain(project: &Project, cid: ItemId, open: &[ItemId]) -> Vec { + let mut v = vec![cid]; + let mut cur = cid; + while v.len() < 32 { + let ups = upstream(project, cur); + let Some(next) = open.iter().rev().find(|o| ups.contains(o)).or(ups.first()).copied() else { break }; + if v.contains(&next) { + break; + } + v.push(next); + cur = next; + } + v.reverse(); + v +} + +fn name(project: &Project, id: ItemId) -> String { + project.item(id).map(|i| i.name.clone()).unwrap_or_default() +} + +// ---------------------------------------------------------------- Pre-compose dialog + +#[derive(Clone, Debug, Default)] +pub struct PrecomposeDraft { + pub layers: Vec, + pub name: String, + /// "Leave all attributes in" (only one footage/solid/precomp layer). + pub leave: bool, + pub can_leave: bool, + pub layer_name: String, + pub adjust: bool, + pub open: bool, +} + +/// Open the Pre-compose dialog for the selected (or given) layers. +pub fn open(app: &mut EffectcraftApp, p: &serde_json::Value) -> Result<(), String> { + let s = &app.session; + let c = s.active_comp().ok_or("no composition is open")?; + let layers: Vec = match p.get("layers").and_then(|v| v.as_array()) { + Some(a) => a.iter().filter_map(|v| v.as_u64()).collect(), + None => s.state.selected_layers.iter().map(|l| l.0).collect(), + }; + if layers.is_empty() { + return Err("select the layers to pre-compose".into()); + } + let first = c.layer(effectcraft_engine::project::LayerId(layers[0])); + let can_leave = layers.len() == 1 && first.is_some_and(|l| l.source.item().is_some()); + let n = s.project.comps().count() + 1; + let base = if layers.len() == 1 { first.map(|l| format!("{} Comp 1", l.name)) } else { None }; + app.dialog_state.precompose = PrecomposeDraft { + layers, + name: base.unwrap_or_else(|| format!("Pre-comp {n}")), + leave: false, + can_leave, + layer_name: first.map(|l| l.name.clone()).unwrap_or_default(), + adjust: false, + open: true, + }; + app.dialog = Some(Dialog::Precompose); + Ok(()) +} + +/// The `layer.precompose` parameters of the dialog. +pub fn draft_params(d: &PrecomposeDraft) -> serde_json::Value { + json!({ + "layers": d.layers, + "name": d.name, + "mode": if d.leave && d.can_leave { "leave" } else { "move" }, + "adjustDuration": d.adjust && !(d.leave && d.can_leave), + "open": d.open, + }) +} + +pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.precompose.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Pre-compose", vec2(520.0, 360.0), t, |ui| { + ui.horizontal(|ui| { + ui.label("New composition name:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); + app.auto.add("dialog.precompose.name", r.rect, "New composition name"); + }); + ui.add_space(10.0); + ui.add_enabled_ui(d.can_leave, |ui| { + let r = ui.radio_value(&mut d.leave, true, format!("Leave all attributes in \"{}\"", d.layer_name)); + app.auto.add("dialog.precompose.leave", r.rect, "Leave all attributes"); + ui.label( + egui::RichText::new(format!( + "Use this option to create a new intermediate composition with only \"{}\" in it. The new composition will become the source to the current layer.", + d.layer_name + )) + .color(t.text_dim) + .size(11.0), + ); + }); + ui.add_space(6.0); + let r = ui.radio_value(&mut d.leave, false, "Move all attributes into the new composition"); + app.auto.add("dialog.precompose.move", r.rect, "Move all attributes"); + ui.label( + egui::RichText::new("Use this option to place the currently selected layers together into a new intermediate composition.") + .color(t.text_dim) + .size(11.0), + ); + ui.add_space(4.0); + ui.add_enabled_ui(!d.leave, |ui| { + let r = ui.checkbox(&mut d.adjust, "Adjust composition duration to the time span of the selected layers"); + app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration"); + }); + ui.add_space(8.0); + let r = ui.checkbox(&mut d.open, "Open New Composition"); + app.auto.add("dialog.precompose.open", r.rect, "Open New Composition"); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + app.auto.add("dialog.precompose.ok", r.rect, "OK"); + ok = r.clicked(); + let r = ui.button("Cancel"); + app.auto.add("dialog.precompose.cancel", r.rect, "Cancel"); + cancel = r.clicked(); + }); + }); + app.dialog_state.precompose = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + if let Err(e) = app.session.execute("layer.precompose", draft_params(&d)) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +// ---------------------------------------------------------------- navigator + mini-flowchart + +/// Height of the Composition Navigator bar. +pub const NAV_H: f32 = 22.0; + +/// Whether the active comp has a flow to show (it is nested somewhere or nests others). +pub fn has_flow(app: &EffectcraftApp) -> bool { + app.session.active_comp_id().is_some_and(|c| !upstream(&app.session.project, c).is_empty() || !downstream(&app.session.project, c).is_empty()) +} + +/// The Composition Navigator bar: the upstream chain › active comp › (dim) its precomps. +pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let Some(cid) = app.session.active_comp_id() else { return }; + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.panel_bg); + p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); + let proj = app.session.project.clone(); + let ch = chain(&proj, cid, &app.session.state.open_comps); + let down = downstream(&proj, cid); + let mut x = rect.min.x + 10.0; + let cy = rect.center().y; + let mut open: Option = None; + let items: Vec<(ItemId, bool)> = ch.iter().map(|c| (*c, false)).chain(down.iter().take(1).map(|c| (*c, true))).collect(); + for (i, (id, downstream_item)) in items.iter().enumerate() { + if i > 0 { + crate::icons::paint(&p, Rect::from_center_size(pos2(x + 6.0, cy), vec2(8.0, 8.0)), crate::icons::Icon::ChevronRight, t.text_faint); + x += 16.0; + } + let active = *id == cid; + let label = name(&proj, *id); + let col = if active { + t.tab_text_active + } else if *downstream_item { + t.text_faint + } else { + t.text_dim + }; + let g = p.layout_no_wrap(label.clone(), Tokens::ui(11.5), col); + let r = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(g.size().x + 12.0, rect.height() - 4.0)); + let resp = ui.interact(r, egui::Id::new(("comp-nav", id.0)), Sense::click()); + if active { + p.rect_filled(r, 3.0, t.row_selected); + } else if resp.hovered() { + p.rect_filled(r, 3.0, t.hover); + } + p.galley(pos2(r.min.x + 6.0, cy - g.size().y / 2.0), g, col); + app.auto.add(&format!("viewer.navigator.{}", id.0), r, &label); + if resp.clicked() && !active { + open = Some(*id); + } + x = r.max.x + 2.0; + } + let fr = Rect::from_min_size(pos2(rect.max.x - 26.0, rect.min.y + 2.0), vec2(22.0, rect.height() - 4.0)); + if crate::widgets::icon_button(ui, fr, crate::icons::Icon::Flowchart, false, &t, egui::Id::new("comp-nav-flow")) + .on_hover_text("Composition Mini-Flowchart") + .clicked() + { + app.ui.mini_flowchart = Some([fr.min.x, fr.max.y]); + } + app.auto.add("viewer.navigator.flowchart", fr, "Composition Mini-Flowchart"); + if let Some(id) = open { + app.session.open_comp(id); + } +} + +/// Composition Mini-Flowchart: upstream comps → active → downstream comps; click to open. +pub fn mini_flowchart(app: &mut EffectcraftApp, ctx: &egui::Context) { + let Some(at) = app.ui.mini_flowchart else { return }; + let Some(cid) = app.session.active_comp_id() else { + app.ui.mini_flowchart = None; + return; + }; + let t = app.tokens; + let proj = app.session.project.clone(); + let ups = upstream(&proj, cid); + let downs = downstream(&proj, cid); + let mut open = None; + let mut close = false; + let area = egui::Area::new(egui::Id::new("mini-flowchart")).order(egui::Order::Foreground).constrain(true).fixed_pos(pos2(at[0], at[1])).show(ctx, |ui| { + egui::Frame::popup(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + let col_w = 160.0; + let rows = ups.len().max(downs.len()).max(1) as f32; + let h = rows * 26.0 + 8.0; + let (rect, _) = ui.allocate_exact_size(vec2(col_w * 3.0 + 60.0, h), Sense::hover()); + let p = ui.painter().clone(); + let node = |ui: &mut egui::Ui, app: &mut EffectcraftApp, r: Rect, id: ItemId, active: bool| { + let resp = ui.interact(r, egui::Id::new(("mfc", id.0)), Sense::click()); + p.rect_filled( + r, + 4.0, + if active { + t.accent + } else if resp.hovered() { + t.hover + } else { + t.field_bg + }, + ); + p.text(r.center(), Align2::CENTER_CENTER, name(&proj, id), Tokens::ui(11.5), if active { Color32::WHITE } else { t.text }); + app.auto.add(&format!("flowchart.mini.{}", id.0), r, &name(&proj, id)); + resp.clicked() + }; + let mid = Rect::from_center_size(rect.center(), vec2(col_w, 22.0)); + if node(ui, app, mid, cid, true) { + close = true; + } + let col = |i: usize, n: usize, x: f32| { + let y0 = rect.center().y - (n as f32 * 26.0) / 2.0 + 13.0; + Rect::from_center_size(pos2(x, y0 + i as f32 * 26.0), vec2(col_w, 22.0)) + }; + let arrow = |a: egui::Pos2, b: egui::Pos2| { + p.line_segment([a, b], Stroke::new(1.2, t.text_dim)); + let d = (b - a).normalized(); + let n = vec2(-d.y, d.x); + p.add(egui::Shape::convex_polygon(vec![b, b - d * 7.0 + n * 3.5, b - d * 7.0 - n * 3.5], t.text_dim, Stroke::NONE)); + }; + for (i, u) in ups.iter().enumerate() { + let r = col(i, ups.len(), rect.min.x + col_w / 2.0); + // Upstream comps contain the active one: arrow from the nested comp up into them. + arrow(mid.left_center(), r.right_center()); + if node(ui, app, r, *u, false) { + open = Some(*u); + } + } + for (i, d) in downs.iter().enumerate() { + let r = col(i, downs.len(), rect.max.x - col_w / 2.0); + arrow(r.left_center(), mid.right_center()); + if node(ui, app, r, *d, false) { + open = Some(*d); + } + } + if ups.is_empty() && downs.is_empty() { + p.text(pos2(rect.center().x, rect.max.y - 4.0), Align2::CENTER_BOTTOM, "No nested compositions", Tokens::ui(10.5), t.text_faint); + } + }); + }); + let outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer(); + if let Some(id) = open { + app.session.open_comp(id); + close = true; + } + if close || outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.mini_flowchart = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::Session; + + #[test] + fn flow_chain_and_precompose_dialog_params() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 25, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Mid", "open": true})).unwrap(); + let mid = ItemId(r["comp"].as_u64().unwrap()); + let l2 = s.project.comp(mid).unwrap().layers[0].id.0; + let r = s.execute("layer.precompose", json!({"layers": [l2], "name": "Inner", "open": true})).unwrap(); + let inner = ItemId(r["comp"].as_u64().unwrap()); + assert_eq!(upstream(&s.project, inner), vec![mid]); + assert_eq!(downstream(&s.project, main), vec![mid]); + assert_eq!(chain(&s.project, inner, &s.state.open_comps), vec![main, mid, inner]); + assert_eq!(chain(&s.project, main, &[]), vec![main]); + + let mut app = EffectcraftApp::new(s); + app.session.open_comp(main); + let top = app.session.active_comp().unwrap().layers[0].id.0; + app.session.execute("layer.select", json!({"layers": [top]})).unwrap(); + open(&mut app, &json!({})).unwrap(); + assert_eq!(app.dialog, Some(Dialog::Precompose)); + let d = &mut app.dialog_state.precompose; + assert!(d.can_leave); + d.leave = true; + d.adjust = true; + let p = draft_params(d); + assert_eq!(p["mode"], "leave"); + assert_eq!(p["adjustDuration"], false); + d.leave = false; + assert_eq!(draft_params(d)["mode"], "move"); + assert_eq!(draft_params(d)["adjustDuration"], true); + } +} diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 31d7d4de..1824fec5 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -1,5 +1,9 @@ -//! The Project panel: item preview header, search, item list with columns, folders, and the -//! bottom bar (new folder, new comp, bit depth, delete). +//! The Project panel: item preview header (rendered thumbnail of the comp / footage frame), +//! search, item list with columns (Name, Label, then the visible optional columns: Type, Size, +//! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context +//! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name), +//! the label colour picker, and the bottom bar (interpret, new folder, new comp, bit depth, +//! delete). Edits are `project.*` engine commands, so they are undoable. use effectcraft_engine::project::{Item, ItemId, ItemKind}; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; @@ -52,6 +56,23 @@ fn file_size(it: &Item) -> Option { } } +fn file_path(it: &Item) -> &str { + match &it.kind { + ItemKind::Footage(f) => &f.path, + _ => "", + } +} + +/// Optional columns: (key, header, width). +pub const COLUMNS: [(&str, &str, f32); 6] = [ + ("type", "Type", 84.0), + ("size", "Size", 56.0), + ("duration", "Media Duration", 100.0), + ("fps", "Frame Rate", 72.0), + ("path", "File Path", 220.0), + ("comment", "Comment", 150.0), +]; + /// Sort siblings by the Project panel's sort column; ties fall back to the name. pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { let name = |i: &Item| i.name.to_lowercase(); @@ -60,6 +81,10 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { "type" => a.type_name().cmp(b.type_name()), "size" => file_size(a).cmp(&file_size(b)), "fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal), + "duration" => a.duration().cmp(&b.duration()), + "path" => file_path(a).cmp(file_path(b)), + "comment" => a.comment.cmp(&b.comment), + "label" => (a.label as u8).cmp(&(b.label as u8)), _ => std::cmp::Ordering::Equal, } .then_with(|| name(a).cmp(&name(b))); @@ -67,6 +92,69 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { }); } +/// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root. +pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option) -> Option { + let it = project.item(row?)?; + if it.is_folder() { Some(it.id) } else { it.parent } +} + +/// Thumbnail of a comp (its current time) or footage (first frame), cached per item/revision. +fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, it: &Item) -> Option { + let key = egui::Id::new(("proj-thumb", it.id.0)); + let rev = app.session.revision; + if let Some((r, tex)) = ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(key)) + && r == rev + { + return Some(tex); + } + let (w, h, px) = match &it.kind { + ItemKind::Comp(_) => { + let t = app.session.state.times.get(&it.id).copied().unwrap_or_default(); + app.session.render_rgba8(it.id, t, 192).ok()? + } + ItemKind::Footage(f) if f.has_video => { + let img = app.session.footage.frame(it.id, f, effectcraft_engine::time::Tick(0))?; + // Point-sample down to at most 192 px on the long side. + let step = (img.width.max(img.height) as f32 / 192.0).ceil().max(1.0) as u32; + let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + let full = img.to_rgba8(); + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let i = (((y * step) * img.width + x * step) * 4) as usize; + px.extend_from_slice(&full[i..i + 4]); + } + } + (w, h, px) + } + _ => return None, + }; + let ci = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px); + let tex = ctx.load_texture(format!("proj-thumb-{}", it.id.0), ci, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, (rev, tex.clone()))); + Some(tex) +} + +fn fit(r: Rect, w: f32, h: f32) -> Rect { + let s = (r.width() / w.max(1.0)).min(r.height() / h.max(1.0)); + Rect::from_center_size(r.center(), vec2(w * s, h * s)) +} + +/// An inline text edit in progress: (item, field `name`|`comment`, text). +type Editing = (u64, String, String); +fn edit_id() -> egui::Id { + egui::Id::new("proj-inline-edit") +} + +/// Start renaming the selected item (Enter in the Project panel). +pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) { + if let Some(id) = app.session.state.project_selection.first() + && let Some(it) = app.session.project.item(*id) + { + ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone()))); + } +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); @@ -81,17 +169,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ItemKind::Solid(s) => { p.rect_filled(thumb.shrink(4.0), 2.0, Color32::from_rgb((s.color[0] * 255.0) as u8, (s.color[1] * 255.0) as u8, (s.color[2] * 255.0) as u8)); } - ItemKind::Comp(_) => { - if let Some((tex, k)) = &app.viewer_tex - && k.comp == it.id.0 - { - p.image(tex.id(), thumb, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } else { - icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), Icon::Comp, t.text_dim); + _ => match thumbnail(app, &ctx, &it) { + Some(tex) => { + let [w, h] = tex.size(); + p.image(tex.id(), fit(thumb, w as f32, h as f32), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } - } - _ => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim), + None => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim), + }, } + app.auto.add("project.thumbnail", thumb, &it.name); let tx = thumb.max.x + 10.0; p.text(pos2(tx, thumb.min.y + 6.0), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), t.text); let mut lines = vec![]; @@ -117,44 +203,50 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::search_field(ui, sr, &mut q, "Search project", &t); app.ui.project_search = q; app.auto.add("project.search", sr, "Search"); - // Column header. + // Column header: Name, Label, then the visible optional columns. let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0)); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); - let name_w = (rect.width() * 0.40).max(120.0); - let col_type = rect.min.x + name_w + 30.0; - let col_size = col_type + 80.0; - let col_fps = col_size + 52.0; - // Clickable headers sort (click again to reverse), with AE's ▲/▼ indicator. - let cols = [ - ("name", "Name", rect.min.x + 26.0, name_w - 26.0), - ("type", "Type", col_type, 76.0), - ("size", "Size", col_size, 48.0), - ("fps", "Frame Rate", col_fps, 70.0), - ]; - for (key, label, x, w) in cols { - let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(w, hdr.height())); - let resp = ui.interact(hr, egui::Id::new(("proj-sort", key)), Sense::click()); - p.with_clip_rect(hr).text(pos2(x, hdr.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); - if app.ui.project_sort == key { + let name_w = (rect.width() * 0.40).clamp(120.0, 260.0); + let label_x = rect.min.x + name_w + 14.0; + let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)]; + let mut x = label_x + 16.0; + for key in app.ui.project_columns.clone() { + if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) { + cols.push((k, l, x, *w)); + x += w + 4.0; + } + } + let hp = p.with_clip_rect(hdr); + for (key, label, x, w) in &cols { + let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height())); + let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); + hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + if *key == "label" { + icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); + } + if app.ui.project_sort == *key { let c = pos2(hr.max.x - 10.0, hdr.center().y); let d = if app.ui.project_sort_desc { 1.0 } else { -1.0 }; - p.add(egui::Shape::convex_polygon( + hp.add(egui::Shape::convex_polygon( vec![pos2(c.x - 4.0, c.y - 2.0 * d), pos2(c.x + 4.0, c.y - 2.0 * d), pos2(c.x, c.y + 2.5 * d)], t.text_dim, Stroke::NONE, )); } - app.auto.add(&format!("project.sort.{key}"), hr, label); + app.auto.add(&format!("project.sort.{key}"), hr, if label.is_empty() { "Label" } else { label }); if resp.clicked() { - if app.ui.project_sort == key { + if app.ui.project_sort == *key { app.ui.project_sort_desc = !app.ui.project_sort_desc; } else { app.ui.project_sort = key.to_string(); app.ui.project_sort_desc = false; } } + column_menu(app, &resp); } - icons::paint(&p, Rect::from_center_size(pos2(rect.min.x + name_w + 14.0, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); + let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::click()); + column_menu(app, &hresp); + app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)"); // Rows. let footer_h = 28.0; let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); @@ -176,6 +268,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } walk(app, None, 0, &query, &mut rows); + // Enter renames the selected item (Project panel focused, not typing). + if app.ui.focused == crate::dock::PanelKind::Project + && app.dialog.is_none() + && !ctx.egui_wants_keyboard_input() + && ctx.data(|d| d.get_temp::(edit_id())).is_none() + && ctx.input(|i| i.key_pressed(egui::Key::Enter)) + { + begin_rename(app, &ctx); + } + let editing: Option = ctx.data(|d| d.get_temp(edit_id())); + let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p { + DragPayload::Item(i) => Some(i), + _ => None, + }); + let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y); + let mut drop_row: Option = None; let mut actions: Vec<(String, serde_json::Value)> = vec![]; for (i, (id, depth)) in rows.iter().enumerate() { let Some(it) = app.session.project.item(*id).cloned() else { continue }; @@ -184,6 +292,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if r.min.y > list.max.y { break; } + if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) { + drop_row = Some(*id); + } let selected = app.session.state.project_selection.contains(id); lp.rect_filled( r, @@ -207,26 +318,93 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.project_open_folders.insert(id.0); } } + app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name); } icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim); - let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 6.0, r.max.y)).intersect(list); - lp.with_clip_rect(name_clip).text( - pos2(x0 + 30.0, r.center().y), - Align2::LEFT_CENTER, - &it.name, - Tokens::ui(12.0), - if selected { Color32::WHITE } else { t.text }, - ); - lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, t.label(it.label)); - lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim); - if let Some(b) = file_size(&it) { - lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim); + let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list); + let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0)); + // Inline edits (rename / comment). + let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool { + let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell)); + let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0))); + app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field); + if resp.lost_focus() { + ctx.data_mut(|d| d.remove::(edit_id())); + if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let (cmd, params) = if ef == "name" { + ("project.rename", json!({"item": eid, "name": buf})) + } else { + ("project.setComment", json!({"items": [eid], "comment": buf})) + }; + actions.push((cmd.into(), params)); + } + } else { + resp.request_focus(); + ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf))); + } + true + }; + if !cell_edit(app, ui, &mut actions, "name", name_rect) { + lp.with_clip_rect(name_clip).text( + pos2(x0 + 30.0, r.center().y), + Align2::LEFT_CENTER, + &it.name, + Tokens::ui(12.0), + if selected { Color32::WHITE } else { t.text }, + ); } - if let Some(fr) = it.frame_rate() { - lp.text(pos2(col_fps + 66.0, r.center().y), Align2::RIGHT_CENTER, format!("{:.2}", fr.as_f64()), Tokens::ui(11.5), t.text_dim); + // Label swatch: click for the label colour menu. + let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0)); + lp.rect_filled(sw, 2.0, t.label(it.label)); + let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click()); + app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name()); + let pop = egui::Id::new(("plabel-pop", id.0)); + if sresp.clicked() { + widgets::open_popup(ui, pop); + } + let names: Vec = effectcraft_engine::color::Label::ALL.iter().map(|l| app.session.prefs.label_name(*l)).collect(); + let cur = effectcraft_engine::color::Label::ALL.iter().position(|l| *l == it.label); + if let Some(li) = widgets::popup_menu(ui, pop, sw.left_bottom(), &names, cur) { + let items: Vec = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] }; + actions.push(("project.setLabel".into(), json!({"items": items, "label": li}))); + } + // Optional columns. + for (key, _, cx, w) in cols.iter().skip(2) { + let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list); + let cp = lp.with_clip_rect(cell); + let txt = match *key { + "type" => it.type_name().to_string(), + "size" => file_size(&it).map(fmt_size).unwrap_or_default(), + "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(), + "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(), + "path" => file_path(&it).to_string(), + "comment" => { + let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0)); + if cell_edit(app, ui, &mut actions, "comment", cr) { + continue; + } + let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::click()); + app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment); + if cresp.double_clicked() { + ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone()))); + } + it.comment.clone() + } + _ => String::new(), + }; + let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER }; + let tx = if *key == "fps" { cx + w - 6.0 } else { *cx }; + cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim); + } + if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() { + lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } let resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag()); app.auto.add(&format!("project.item.{}", id.0), r, &it.name); + let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag()); + app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name); + let resp = resp.union(nresp.clone()); if resp.clicked() { let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); if add { @@ -237,7 +415,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.project_selection = vec![*id]; } } - if resp.double_clicked() { + if nresp.double_clicked() { + // Double-click the name: rename in place. + ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone()))); + } else if resp.double_clicked() { match &it.kind { ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))), ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => { @@ -246,7 +427,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => {} } } - if resp.drag_started() && !it.is_folder() { + if resp.drag_started() { egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0)); } resp.context_menu(|ui| { @@ -254,6 +435,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("comp.open".into(), json!({"comp": id.0}))); ui.close(); } + if ui.button("Rename").clicked() { + ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone()))); + ui.close(); + } + if it.parent.is_some() && ui.button("Move to Project Root").clicked() { + actions.push(("project.move".into(), json!({"items": [id.0], "folder": null}))); + ui.close(); + } if !it.is_folder() && ui.button("Add to Composition").clicked() { actions.push(("layer.addItem".into(), json!({"item": id.0}))); ui.close(); @@ -268,6 +457,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); } + // Dropping a dragged item on the list: into the folder under the pointer (or the folder of + // the item under it), or the project root below the rows. + if let Some(iid) = dragging + && hover_y.is_some() + { + if drop_row.is_none() { + lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside); + } + if ctx.input(|i| i.pointer.any_released()) && drop_row != Some(ItemId(iid)) { + let folder = drop_folder(&app.session.project, drop_row); + let cur = app.session.project.item(ItemId(iid)).and_then(|i| i.parent); + if folder != cur { + let moving: Vec = if app.session.state.project_selection.contains(&ItemId(iid)) { + app.session.state.project_selection.iter().map(|i| i.0).collect() + } else { + vec![iid] + }; + actions.push(("project.move".into(), json!({"items": moving, "folder": folder.map(|f| f.0)}))); + if let Some(f) = folder { + app.ui.project_open_folders.insert(f.0); + } + } + egui::DragAndDrop::clear_payload(&ctx); + } + } // Drag ghost. if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(&ctx).as_deref() && let Some(pos) = ctx.pointer_hover_pos() @@ -294,13 +508,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.clicked() { match id { "newComp" => crate::panels::dialogs::open_new_comp(app), - "newFolder" => { - let _ = app.session.edit("New Folder", None, |proj, st| { - let id = proj.add_item("Untitled Folder", effectcraft_engine::color::Label::Yellow, None, ItemKind::Folder); - st.project_selection = vec![id]; - Ok(()) - }); - } + "newFolder" => actions.push(("project.newFolder".into(), json!({}))), _ => app.ui.status = "Interpret Footage: select a footage item".into(), } } @@ -340,6 +548,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The column header's context menu: show or hide the optional columns. +fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { + resp.context_menu(|ui| { + ui.label(egui::RichText::new("Columns").weak()); + for (key, label, _) in COLUMNS { + let mut on = app.ui.project_columns.iter().any(|c| c == key); + if ui.checkbox(&mut on, label).changed() { + set_column(&mut app.ui.project_columns, key, on); + } + } + }); +} + +/// Show or hide a column, keeping the canonical column order. +pub fn set_column(cols: &mut Vec, key: &str, on: bool) { + cols.retain(|c| c != key); + if on { + cols.push(key.to_string()); + cols.sort_by_key(|c| COLUMNS.iter().position(|x| x.0 == c).unwrap_or(usize::MAX)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -366,4 +596,20 @@ mod tests { sort_items(&mut kids, "name", true); assert_eq!(kids[0].name, "Solids"); } + + #[test] + fn drop_targets_and_column_toggles() { + let mut p = effectcraft_engine::project::Project::default(); + let l = effectcraft_engine::color::Label::Yellow; + let f = p.add_item("F", l, None, ItemKind::Folder); + let inner = p.add_item("I", l, Some(f), ItemKind::Folder); + assert_eq!(drop_folder(&p, Some(f)), Some(f)); + assert_eq!(drop_folder(&p, Some(inner)), Some(inner)); + assert_eq!(drop_folder(&p, None), None); + let mut cols = crate::state::default_project_columns(); + set_column(&mut cols, "comment", true); + set_column(&mut cols, "path", true); + set_column(&mut cols, "size", false); + assert_eq!(cols, ["type", "duration", "fps", "path", "comment"]); + } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 78f62491..07c12fb6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -630,15 +630,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } s += secs_per_label; } - // Comp markers. - for m in &comp.markers { - let x = tm.x(m.time.seconds()); - pr.add(egui::Shape::convex_polygon( - vec![pos2(x - 4.0, ruler.max.y - 10.0), pos2(x + 4.0, ruler.max.y - 10.0), pos2(x, ruler.max.y - 4.0)], - t.label(m.label), - Stroke::NONE, - )); - } // Scrub in the ruler. let rresp = ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), ruler.max), egui::Id::new("tl-ruler"), Sense::click_and_drag()); if (rresp.dragged() || rresp.clicked()) @@ -1031,15 +1022,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::EndMerge); } } - // Markers on the layer. - for m in &layer.markers { - let x = tm.x(layer.comp_time(m.time).seconds()); - gp.add(egui::Shape::convex_polygon( - vec![pos2(x - 3.5, r.min.y + 3.0), pos2(x + 3.5, r.min.y + 3.0), pos2(x, r.min.y + 9.0)], - Color32::from_rgb(0xd8, 0xd8, 0x60), - Stroke::NONE, - )); - } + // Markers on the layer (drag, Alt-drag duration, Cmd-click delete, dialog). + let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r); } } RowKind::Group { uid, name, open, has_children, fx, eye } => { @@ -1501,6 +1486,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.timeline.pps = Some(npps); } + // Composition markers in the ruler; protected regions shade the layer rows. + let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(graph_x1 + 10.0, ruler.max.y)); + let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows); + // Drop targets: footage/comps from the Project panel, effects from Effects & Presets. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rows_rect) @@ -1720,7 +1710,7 @@ fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(S item(ui, "Send Layer to Back", "layer.arrange", json!({"layers": [id], "to": "back"})); }); ui.separator(); - item(ui, "Pre-compose…", "layer.precompose", json!({"layers": [id], "name": format!("{} Comp 1", layer.name)})); + item(ui, "Pre-compose…", "layer.precompose", json!({"layers": [id]})); item(ui, "Duplicate", "edit.duplicate", json!({"layers": [id]})); item(ui, "Split Layer", "edit.splitLayer", json!({"layers": [id]})); item(ui, "Delete", "edit.clear", json!({"layers": [id]})); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 43ca11cb..d1350103 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -399,6 +399,10 @@ fn default_project_sort() -> String { "name".into() } +pub fn default_project_columns() -> Vec { + ["type", "size", "duration", "fps"].map(String::from).to_vec() +} + /// What an Effect Controls crosshair / eyedropper click in the viewer sets. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FxPick { @@ -427,6 +431,48 @@ impl Default for EffectsView { } } +/// Wiggler, Smoother and Motion Sketch panel settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AnimToolsState { + /// Wiggler Apply To: Spatial Path (true) or Temporal Path. + pub wiggle_spatial: bool, + /// Noise Type: Smooth (true) or Jagged. + pub wiggle_smooth: bool, + /// `one` (dimension `wiggle_dim`), `same` or `independent`. + pub wiggle_dims: String, + pub wiggle_dim: usize, + pub wiggle_frequency: f64, + pub wiggle_magnitude: f64, + pub smooth_tolerance: f64, + /// Motion Sketch: capture speed (%), smoothing, show wireframe / background. + pub sketch_speed: f64, + pub sketch_smoothing: f64, + pub sketch_wireframe: bool, + pub sketch_background: bool, + /// Start Capture pressed: the next drag in the viewer records. + pub sketch_armed: bool, +} + +impl Default for AnimToolsState { + fn default() -> Self { + AnimToolsState { + wiggle_spatial: true, + wiggle_smooth: true, + wiggle_dims: "independent".into(), + wiggle_dim: 0, + wiggle_frequency: 5.0, + wiggle_magnitude: 1.0, + smooth_tolerance: 1.0, + sketch_speed: 100.0, + sketch_smoothing: 1.0, + sketch_wireframe: false, + sketch_background: true, + sketch_armed: false, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, @@ -437,6 +483,15 @@ pub struct UiState { /// Saved workspace layouts (Save Changes to this Workspace / Save as New Workspace). #[serde(default)] pub saved_workspaces: std::collections::BTreeMap, + /// Undocked (floating) panel groups. + #[serde(default)] + pub floating: Vec, + /// Floating panels of the saved workspaces. + #[serde(default)] + pub saved_floating: std::collections::BTreeMap>, + /// The panel maximized to fill the dock area (`~`), if any. + #[serde(default)] + pub maximized: Option, pub focused: PanelKind, pub viewer: ViewerState, pub timeline: TimelineState, @@ -454,6 +509,10 @@ pub struct UiState { pub project_sort: String, #[serde(default)] pub project_sort_desc: bool, + /// Visible Project panel columns after Name and Label, in order (`type`, `size`, `fps`, + /// `duration`, `path`, `comment`); toggled from the column header's context menu. + #[serde(default = "default_project_columns")] + pub project_columns: Vec, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, /// Slider params whose slider row is twirled open (AE hides sliders by default). @@ -505,6 +564,12 @@ pub struct UiState { /// Layer panel View: number of effects rendered (None = through the last Paint effect). #[serde(default)] pub layer_view: Option, + /// Wiggler / Smoother / Motion Sketch settings. + #[serde(default)] + pub anim_tools: AnimToolsState, + /// Composition Mini-Flowchart popup position (open when set). + #[serde(default)] + pub mini_flowchart: Option<[f32; 2]>, } impl Default for UiState { @@ -515,6 +580,9 @@ impl Default for UiState { workspace: "Default".into(), dock: crate::dock::workspace("Default"), saved_workspaces: Default::default(), + floating: vec![], + saved_floating: Default::default(), + maximized: None, focused: PanelKind::Composition, viewer: ViewerState::default(), timeline: TimelineState::default(), @@ -527,6 +595,7 @@ impl Default for UiState { project_open_folders: BTreeSet::new(), project_sort: default_project_sort(), project_sort_desc: false, + project_columns: default_project_columns(), fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), fx_pick: None, @@ -549,6 +618,8 @@ impl Default for UiState { tracker_source: None, layer_panel: None, layer_view: None, + anim_tools: AnimToolsState::default(), + mini_flowchart: None, } } }