Merge main into perf-bpc
This commit is contained in:
commit
5c9038cbee
|
|
@ -1246,6 +1246,7 @@ dependencies = [
|
|||
"effectcraft-render",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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<Keyframe> {
|
||||
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<Keyframe> = 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<f64>)], 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::<f64>().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<f64>)], tolerance: f64, spatial: bool) -> Vec<Keyframe> {
|
||||
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<usize> = vec![];
|
||||
let build = |chosen: &[usize]| {
|
||||
let mut v: Vec<Keyframe> = 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<f64>)> {
|
||||
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<Tick>> {
|
||||
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = 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<WiggleOpts> {
|
||||
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<Keyframe>,
|
||||
) -> Result<Value> {
|
||||
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<Tick> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<f64> = 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<Keyframe> = 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<Keyframe> = 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<f64>)> = 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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.text_edit.is_some() {
|
||||
// Text editing pastes the system clipboard's text (passed as `text`) or copied text.
|
||||
return Ok(());
|
||||
}
|
||||
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() {
|
||||
Err("the clipboard is empty".into())
|
||||
} else {
|
||||
|
|
@ -49,6 +53,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() {
|
||||
return s.execute("text.setSelection", json!({"select": "all"}));
|
||||
}
|
||||
if let Some(c) = s.active_comp() {
|
||||
s.state.selected_layers = c.layers.iter().map(|l| l.id).collect();
|
||||
}
|
||||
|
|
@ -103,6 +110,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return s.execute("text.delete", json!({}));
|
||||
}
|
||||
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("keys.delete", json!({}));
|
||||
|
|
@ -144,6 +154,9 @@ fn clear_clipboards(s: &mut Session) {
|
|||
|
||||
/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers.
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return super::text_edit::copy(s);
|
||||
}
|
||||
// Keyframes selected → copy keys (pasted at the CTI).
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
|
|
@ -322,6 +335,9 @@ fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return super::text_edit::cut(s);
|
||||
}
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.execute("keys.copy", json!({}))?;
|
||||
return s.execute("keys.delete", json!({}));
|
||||
|
|
@ -331,6 +347,9 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() {
|
||||
return super::text_edit::paste(s, p);
|
||||
}
|
||||
if let Some(clip) = s.state.link_clipboard.clone() {
|
||||
return paste_links(s, clip);
|
||||
}
|
||||
|
|
@ -502,6 +521,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("all").to_string();
|
||||
if matches!(what.as_str(), "all" | "snapshot") {
|
||||
s.snapshot = None;
|
||||
s.state.viewer.show_snapshot = false;
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what}))
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
|
|
@ -74,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box
|
||||
//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and
|
||||
//! spatial Bezier tangents dragged on a motion path.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
||||
}
|
||||
|
||||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
let voff = f_p(p, "valueOffset").unwrap_or(0.0);
|
||||
let va = f_p(p, "valueAnchor").unwrap_or(0.0);
|
||||
// `dim` (one dimension) or `dims` (several); none = every dimension.
|
||||
let dims: Option<Vec<usize>> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) {
|
||||
(Some(d), _) => Some(vec![d as usize]),
|
||||
(None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()),
|
||||
_ => None,
|
||||
};
|
||||
if !ts.is_finite() || ts <= 0.0 {
|
||||
return Err(bad("keys.transform", "`timeScale` must be positive"));
|
||||
}
|
||||
// Default time anchor: the earliest selected key (comp time).
|
||||
let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO);
|
||||
let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first);
|
||||
let map_t = |ct: Tick| -> Tick {
|
||||
let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff;
|
||||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
let d = ct - start;
|
||||
if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) }
|
||||
};
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut moved, mut kept): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
for k in &mut moved {
|
||||
k.time = to_layer(map_t(to_comp(k.time)));
|
||||
if touch_values {
|
||||
let mut c = k.value.components();
|
||||
for (d, v) in c.iter_mut().enumerate() {
|
||||
if dims.as_ref().is_none_or(|x| x.contains(&d)) {
|
||||
*v = va + (*v - va) * vs + voff;
|
||||
}
|
||||
}
|
||||
k.value = k.value.with_components(&c);
|
||||
}
|
||||
}
|
||||
// Transformed keys win over unselected keys at the same time.
|
||||
kept.retain(|k| !moved.iter().any(|m| m.time == k.time));
|
||||
moved.sort_by_key(|k| k.time);
|
||||
moved.dedup_by_key(|k| k.time);
|
||||
for k in &moved {
|
||||
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
}
|
||||
kept.extend(moved);
|
||||
kept.sort_by_key(|k| k.time);
|
||||
pr.keys = kept;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
st.selected_keys = new_sel;
|
||||
Ok(json!({"keys": st.selected_keys.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key
|
||||
/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops
|
||||
/// being auto Bezier.
|
||||
fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?;
|
||||
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?);
|
||||
let (tin, tout) = (v3(p.get("in")), v3(p.get("out")));
|
||||
if tin.is_none() && tout.is_none() {
|
||||
return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]"));
|
||||
}
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
}
|
||||
let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i));
|
||||
let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?;
|
||||
let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i);
|
||||
let k = &mut pr.keys[i];
|
||||
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
|
||||
let (ni, no) = match (tin, tout) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(Some(a), None) => (a, if brk { cur_out } else { neg(a) }),
|
||||
(None, Some(b)) => (if brk { cur_in } else { neg(b) }, b),
|
||||
(None, None) => (cur_in, cur_out),
|
||||
};
|
||||
k.spatial_auto = false;
|
||||
k.spatial_continuous = !brk && tin.is_some() != tout.is_some();
|
||||
k.spatial_in = ni;
|
||||
k.spatial_out = no;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, true);
|
||||
Ok(json!({"in": ni, "out": no}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"keys.transform",
|
||||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
cmd!(
|
||||
"keys.setSpatialTangents",
|
||||
"Edit Spatial Tangents",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}",
|
||||
has_comp,
|
||||
spatial_tangents
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -67,92 +67,65 @@ fn new_adjustment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
new_solid_like(s, p, true)
|
||||
}
|
||||
|
||||
fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
|
||||
let mut d = base;
|
||||
/// Parameters of `layer.setText` / `layer.newText` that aren't text attributes.
|
||||
const NOT_ATTRS: &[&str] = &["layer", "layers", "comp", "merge", "text", "range", "position", "edit"];
|
||||
|
||||
/// Apply `layer.setText` keys to `d`: `text` replaces characters `r` (or everything), then each
|
||||
/// character / paragraph / document attribute applies to `r` (or everything).
|
||||
fn apply_text_params(cmd: &str, p: &Value, d: &mut TextDoc, r: Option<std::ops::Range<usize>>) -> Result<()> {
|
||||
let mut r = r;
|
||||
if let Some(t) = str_p(p, "text") {
|
||||
d.text = t.to_string();
|
||||
match r.clone() {
|
||||
Some(rr) => {
|
||||
d.replace_range(rr.clone(), t, None);
|
||||
r = Some(rr.start..rr.start + t.chars().count());
|
||||
}
|
||||
None => d.set_text(t),
|
||||
}
|
||||
}
|
||||
if let Some(v) = f_p(p, "size") {
|
||||
d.size = v.max(1.0);
|
||||
let Some(obj) = p.as_object() else { return Ok(()) };
|
||||
for (k, v) in obj {
|
||||
if NOT_ATTRS.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !d.set_attr(k, v, r.clone()).map_err(|e| bad(cmd, e))? {
|
||||
return Err(bad(cmd, format!("unknown text attribute {k}")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = str_p(p, "font") {
|
||||
d.font = v.to_string();
|
||||
}
|
||||
if let Some(v) = str_p(p, "style") {
|
||||
d.style = v.to_string();
|
||||
}
|
||||
if let Some(c) = color_p(p, "fill") {
|
||||
d.fill = [c[0], c[1], c[2], 1.0];
|
||||
}
|
||||
if let Some(c) = color_p(p, "stroke") {
|
||||
d.stroke = [c[0], c[1], c[2], 1.0];
|
||||
d.apply_stroke = true;
|
||||
}
|
||||
if let Some(v) = f_p(p, "strokeWidth") {
|
||||
d.stroke_width = v.max(0.0);
|
||||
d.apply_stroke = v > 0.0;
|
||||
}
|
||||
if let Some(v) = f_p(p, "tracking") {
|
||||
d.tracking = v;
|
||||
}
|
||||
match p.get("leading") {
|
||||
Some(Value::Number(n)) => d.leading = n.as_f64(),
|
||||
// "auto" or null: Auto Leading (120% of the font size).
|
||||
Some(Value::String(_) | Value::Null) => d.leading = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = b_p(p, "applyFill") {
|
||||
d.apply_fill = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "applyStroke") {
|
||||
d.apply_stroke = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "allCaps") {
|
||||
d.all_caps = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "smallCaps") {
|
||||
d.small_caps = v;
|
||||
}
|
||||
if let Some(v) = f_p(p, "hScale") {
|
||||
d.h_scale = v.clamp(1.0, 1000.0);
|
||||
}
|
||||
if let Some(v) = f_p(p, "vScale") {
|
||||
d.v_scale = v.clamp(1.0, 1000.0);
|
||||
}
|
||||
if let Some(v) = f_p(p, "baselineShift") {
|
||||
d.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "strokeOverFill") {
|
||||
d.stroke_over_fill = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "fauxBold") {
|
||||
d.faux_bold = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "fauxItalic") {
|
||||
d.faux_italic = v;
|
||||
}
|
||||
if let Some(j) = str_p(p, "justify") {
|
||||
// AE's seven Paragraph alignment buttons.
|
||||
d.justify = match j.to_ascii_lowercase().as_str() {
|
||||
"center" | "centre" => Justify::Center,
|
||||
"right" => Justify::Right,
|
||||
"justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft,
|
||||
"justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter,
|
||||
"justifyright" | "justifylastright" => Justify::JustifyLastRight,
|
||||
"justifyall" => Justify::JustifyAll,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
}
|
||||
d
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
let a = p.get("range")?.as_array()?;
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
|
||||
let (x, y) = (g(0)?, g(1).or(g(0))?);
|
||||
Some(x.min(y)..x.max(y))
|
||||
}
|
||||
|
||||
fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let doc = text_doc_from(p, TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() });
|
||||
let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
let mut doc = TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() };
|
||||
// A paragraph box given in comp space: the layer sits at its centre.
|
||||
let bx = p.get("box").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)));
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("box");
|
||||
}
|
||||
apply_text_params("layer.newText", &q, &mut doc, None)?;
|
||||
let mut pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
if let Some([x, y, w, h]) = bx {
|
||||
let (w, h) = (w.abs().max(1.0), h.abs().max(1.0));
|
||||
doc.box_size = Some([w, h]);
|
||||
doc.box_pos = [-w / 2.0, -h / 2.0];
|
||||
pos = Some([x + w / 2.0, y + h / 2.0]);
|
||||
if p.get("justify").is_none() {
|
||||
doc.set_attr("justify", &json!("left"), None).map_err(|e| bad("layer.newText", e))?;
|
||||
}
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = doc.text.lines().next().unwrap_or("Text").chars().take(40).collect();
|
||||
let name: String = doc.text.lines().next().unwrap_or("").chars().take(40).collect();
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc.clone()));
|
||||
|
|
@ -162,7 +135,12 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
{
|
||||
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
|
||||
}
|
||||
insert_layer(proj, st, cid, l)
|
||||
let n = doc.char_len();
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
if edit {
|
||||
st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() });
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
|
@ -715,15 +693,65 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
||||
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 {
|
||||
|
|
@ -733,10 +761,16 @@ fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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));
|
||||
|
|
@ -745,6 +779,9 @@ fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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}))
|
||||
}
|
||||
|
||||
|
|
@ -852,20 +889,40 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.setText")?;
|
||||
let t = s.time();
|
||||
let range = text_range_p(p);
|
||||
// Character attributes with only a caret (no selected text) set the insertion style.
|
||||
if let Some(r) = range.clone().filter(|r| r.is_empty() && str_p(p, "text").is_none())
|
||||
&& s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid)
|
||||
&& let Some(doc) = super::text_edit::layer_doc(s, lid)
|
||||
&& doc.char_len() > 0
|
||||
&& let Some(obj) = p.as_object()
|
||||
{
|
||||
let e = s.state.text_edit.as_mut().expect("checked");
|
||||
let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start));
|
||||
let mut any = false;
|
||||
for (k, v) in obj {
|
||||
any |= effectcraft_keyframe::text_doc::apply_char_attr(&mut st, k, v).map_err(|m| bad("layer.setText", m))?;
|
||||
}
|
||||
if any {
|
||||
e.pending = Some(st);
|
||||
}
|
||||
s.bump();
|
||||
}
|
||||
s.edit("Edit Text", merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?;
|
||||
let cur = match pr.value_at(lt) {
|
||||
let mut doc = match pr.value_at(lt) {
|
||||
KV::Text(d) => *d,
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let doc = text_doc_from(p, cur);
|
||||
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
|
||||
if let Some(first) = doc.text.lines().next()
|
||||
&& str_p(p, "text").is_some()
|
||||
&& !first.is_empty()
|
||||
&& l.name.starts_with("Text")
|
||||
&& (l.name.starts_with("Text") || l.name.is_empty())
|
||||
{
|
||||
l.name = first.chars().take(40).collect();
|
||||
}
|
||||
|
|
@ -973,7 +1030,15 @@ fn with_layer(p: &Value, lid: LayerId) -> Value {
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text),
|
||||
cmd!(
|
||||
"layer.newText",
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
|
||||
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
|
||||
cmd!(
|
||||
|
|
@ -1033,7 +1098,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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",
|
||||
|
|
@ -1058,7 +1131,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, smallCaps?, fauxBold?, fauxItalic?, hScale? %, vScale? %, baselineShift? px, strokeOverFill?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Marker>> {
|
||||
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<usize> {
|
||||
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<Value> {
|
||||
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<Marker>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<LayerId> = 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<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let src: Vec<(LayerId, Vec<Marker>)> = {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
|
@ -20,19 +20,38 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
|||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = match key {
|
||||
/// Index of a mask group by uid, 1-based index or name.
|
||||
fn mask_index(masks: &PropGroup, key: &Value) -> Option<usize> {
|
||||
match key {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))?
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))
|
||||
}
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s)?,
|
||||
_ => return None,
|
||||
};
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = mask_index(masks, key)?;
|
||||
masks.children.get_mut(i)?.as_group_mut()
|
||||
}
|
||||
|
||||
/// Whether a group holds an editable Bezier path (a mask, or a shape layer's Path item).
|
||||
pub(crate) fn is_path_group(g: &PropGroup) -> bool {
|
||||
matches!(g.get("path").map(|p| &p.value), Some(KV::Path(_))) && (matches!(g.kind, effectcraft_project::GroupKind::Mask { .. }) || g.match_id == "path")
|
||||
}
|
||||
|
||||
/// A mask (uid, 1-based index or name) or, by uid, a shape layer's Path item.
|
||||
pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
if let Some(i) = props.sub("masks").and_then(|m| mask_index(m, key)) {
|
||||
return props.sub_mut("masks")?.children.get_mut(i)?.as_group_mut();
|
||||
}
|
||||
let uid = key.as_u64()?;
|
||||
props.find_group_mut(uid).filter(|g| is_path_group(g))
|
||||
}
|
||||
|
||||
/// Edit the mask path of (layer, mask) at the current time.
|
||||
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
|
||||
let (cid, lid) = layer_p(s, p, label)?;
|
||||
|
|
@ -41,8 +60,7 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
|
|||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
|
||||
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
|
||||
let g = path_group(&mut l.props, &key).ok_or_else(|| bad(label, "no such mask or shape path"))?;
|
||||
let uid = g.uid;
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
|
|
@ -186,7 +204,7 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
|
|||
for ((l, m), idx) in &groups {
|
||||
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
|
||||
let lt = layer.layer_time(t);
|
||||
let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue };
|
||||
let Some(g) = layer.props.find_group_mut(*m).filter(|g| is_path_group(g)) else { continue };
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
|
|
@ -233,6 +251,93 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(n)
|
||||
}
|
||||
|
||||
/// Convert Vertex tool: a corner vertex becomes smooth (tangents along its neighbours, a sixth
|
||||
/// of their distance each way), a smooth one becomes a corner (no tangents). `smooth` forces one.
|
||||
fn convert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.convertVertex", "missing `index`"))? as usize;
|
||||
let force = b_p(p, "smooth");
|
||||
let (_, smooth) = edit_path(s, p, "Convert Vertex", |sp| {
|
||||
let n = sp.vertices.len();
|
||||
if i >= n {
|
||||
return Err(bad("mask.convertVertex", "no such vertex"));
|
||||
}
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let is_smooth = sp.in_tangents[i] != [0.0; 2] || sp.out_tangents[i] != [0.0; 2];
|
||||
let make_smooth = force.unwrap_or(!is_smooth);
|
||||
if make_smooth {
|
||||
let prev = if i > 0 {
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if i + 1 < n {
|
||||
Some(i + 1)
|
||||
} else if sp.closed {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let v = sp.vertices[i];
|
||||
let a = prev.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let b = next.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let mut d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0];
|
||||
if d == [0.0, 0.0] {
|
||||
d = [10.0, 0.0];
|
||||
}
|
||||
sp.out_tangents[i] = d;
|
||||
sp.in_tangents[i] = [-d[0], -d[1]];
|
||||
} else {
|
||||
sp.in_tangents[i] = [0.0; 2];
|
||||
sp.out_tangents[i] = [0.0; 2];
|
||||
}
|
||||
Ok(make_smooth)
|
||||
})?;
|
||||
Ok(json!({"smooth": smooth}))
|
||||
}
|
||||
|
||||
/// Split cubic segment `i` (vertex i → i+1) at parameter `t`; the path keeps its shape.
|
||||
pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<usize> {
|
||||
let n = sp.vertices.len();
|
||||
let j = if seg + 1 < n {
|
||||
seg + 1
|
||||
} else if sp.closed && seg + 1 == n {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let p0 = sp.vertices[seg];
|
||||
let p3 = sp.vertices[j];
|
||||
let p1 = [p0[0] + sp.out_tangents[seg][0], p0[1] + sp.out_tangents[seg][1]];
|
||||
let p2 = [p3[0] + sp.in_tangents[j][0], p3[1] + sp.in_tangents[j][1]];
|
||||
let lerp = |a: [f64; 2], b: [f64; 2]| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
let (a, b, c) = (lerp(p0, p1), lerp(p1, p2), lerp(p2, p3));
|
||||
let (d, e) = (lerp(a, b), lerp(b, c));
|
||||
let m = lerp(d, e);
|
||||
sp.out_tangents[seg] = [a[0] - p0[0], a[1] - p0[1]];
|
||||
sp.in_tangents[j] = [c[0] - p3[0], c[1] - p3[1]];
|
||||
let at = seg + 1;
|
||||
sp.vertices.insert(at, m);
|
||||
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
|
||||
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
|
||||
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg =
|
||||
p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize;
|
||||
let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
let (_, lid) = layer_p(s, p, "mask.insertVertex")?;
|
||||
let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
Ok(json!(i))
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.remove")?;
|
||||
let key = p.get("mask").cloned();
|
||||
|
|
@ -263,6 +368,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
|
||||
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
|
||||
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -11,14 +12,17 @@ mod file;
|
|||
mod file_more;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
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;
|
||||
|
|
@ -27,10 +31,14 @@ mod settings;
|
|||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
pub mod text_edit;
|
||||
mod three_d;
|
||||
mod time;
|
||||
mod track;
|
||||
mod view;
|
||||
pub mod viewer_cmds;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -92,10 +100,14 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(styles::specs());
|
||||
v.extend(three_d::specs());
|
||||
v.extend(text_anim::specs());
|
||||
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());
|
||||
|
|
@ -106,6 +118,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(layer_menu::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(comp_more::specs());
|
||||
|
|
|
|||
|
|
@ -63,6 +63,13 @@ fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
|
|||
v.dedup();
|
||||
return Ok((cid, v));
|
||||
}
|
||||
// A shape layer's Path item by uid.
|
||||
if let Some(uid) = p.get("mask").and_then(Value::as_u64) {
|
||||
let (cid, lid) = layer_p(s, p, "path")?;
|
||||
if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") {
|
||||
return Ok((cid, vec![(lid, uid)]));
|
||||
}
|
||||
}
|
||||
target_masks(s, p)
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +237,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
|
||||
let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue };
|
||||
let roto = is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
|
|
|
|||
|
|
@ -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<ItemId> {
|
||||
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<Vec<ItemId>> {
|
||||
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<Value> {
|
||||
let ids = match p.get("items") {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| item_ref(s, v, "project.select")).collect::<Result<Vec<_>>>()?,
|
||||
_ => 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::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
|
@ -26,9 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
// Text editing in the viewer.
|
||||
stub!("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", ["Edit"], "{}"),
|
||||
stub!("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", ["Edit"], "{}"),
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
|
|
@ -37,7 +34,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// 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"], "{}"),
|
||||
|
|
@ -56,7 +52,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"),
|
||||
// Color management.
|
||||
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
|
||||
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,540 @@
|
|||
//! On-canvas text editing (Type tool): the edit session (layer, selection, insertion style), caret
|
||||
//! movement, typing, deleting, the text clipboard and Paste Text and Match Formatting / Paste Text
|
||||
//! Formatting Only. Every change to the text goes through an undoable edit of Source Text at the
|
||||
//! CTI; the selection lives in [`EditorState::text_edit`] so agents and the UI share it.
|
||||
//!
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{LayerId, LayerSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
|
||||
use crate::{EditorState, EngineError, Result, Session, cmd};
|
||||
|
||||
/// An on-canvas text editing session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextEdit {
|
||||
pub layer: LayerId,
|
||||
pub anchor: usize,
|
||||
pub caret: usize,
|
||||
/// Character panel changes made with only a caret: the style the next typed text takes.
|
||||
#[serde(default)]
|
||||
pub pending: Option<CharStyle>,
|
||||
/// The layer was made by a Type tool click; leaving it empty deletes it.
|
||||
#[serde(default)]
|
||||
pub created: bool,
|
||||
/// Line position kept by repeated up / down moves (layout space).
|
||||
#[serde(default)]
|
||||
pub goal_x: Option<f64>,
|
||||
}
|
||||
|
||||
impl TextEdit {
|
||||
pub fn range(&self) -> std::ops::Range<usize> {
|
||||
self.anchor.min(self.caret)..self.anchor.max(self.caret)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.anchor == self.caret
|
||||
}
|
||||
}
|
||||
|
||||
/// The Source Text document of a layer in the active comp at the CTI (keyframed value).
|
||||
pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
|
||||
let l = s.active_comp()?.layer(lid)?;
|
||||
match l.props.prop("text/sourceText")?.value_at(l.layer_time(s.time())) {
|
||||
KV::Text(d) => Some(*d),
|
||||
KV::Str(t) => Some(TextDoc::plain(&t)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let lid = match p.get("layer") {
|
||||
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
|
||||
None => match &s.state.text_edit {
|
||||
Some(e) => e.layer,
|
||||
None => s
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text)))
|
||||
.ok_or_else(|| bad(cmd, "select a text layer"))?,
|
||||
},
|
||||
};
|
||||
match comp.layer(lid) {
|
||||
Some(l) if matches!(l.source, LayerSource::Text) => Ok(lid),
|
||||
_ => Err(bad(cmd, "not a text layer")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit session on `lid` (started with the caret at the end when there is none).
|
||||
fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
match &s.state.text_edit {
|
||||
Some(e) if e.layer == lid => e.clone(),
|
||||
_ => {
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(e.clone());
|
||||
e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_line_name(text: &str) -> String {
|
||||
text.lines().next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the
|
||||
/// session's selection.
|
||||
pub(crate) fn edit_doc(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
merge: Option<&str>,
|
||||
lid: LayerId,
|
||||
f: impl FnOnce(&mut TextDoc, &mut EditorState) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
|
||||
let mut doc = match pr.value_at(lt) {
|
||||
KV::Text(d) => *d,
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = first_line_name(&doc.text);
|
||||
f(&mut doc, st)?;
|
||||
let after = first_line_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
l.name = after;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
let a = p.get("range")?.as_array()?;
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
|
||||
let (x, y) = (g(0)?, g(1).or(g(0))?);
|
||||
Some(x.min(y)..x.max(y))
|
||||
}
|
||||
|
||||
fn state_json(s: &Session) -> Value {
|
||||
match &s.state.text_edit {
|
||||
Some(e) => json!({"layer": e.layer.0, "anchor": e.anchor, "caret": e.caret, "start": e.range().start, "end": e.range().end}),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.edit`: start editing a text layer.
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.edit")?;
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let (anchor, caret) = match p.get("select") {
|
||||
Some(Value::Array(a)) => {
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).map_or(n, |x| (x as usize).min(n));
|
||||
(g(0), g(1))
|
||||
}
|
||||
Some(Value::String(x)) if x == "none" => (n, n),
|
||||
_ => match p.get("caret").and_then(Value::as_u64) {
|
||||
Some(c) => ((c as usize).min(n), (c as usize).min(n)),
|
||||
None => (0, n),
|
||||
},
|
||||
};
|
||||
let created = b_p(p, "created").unwrap_or(false);
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
||||
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
||||
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
s.edit("Delete Empty Text Layer", None, |proj, st| {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
c.layers.retain(|l| l.id != e.layer);
|
||||
}
|
||||
st.selected_layers.retain(|l| *l != e.layer);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.bump();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn set_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.setSelection")?;
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let mut e = session_on(s, lid);
|
||||
let g = |k: &str| p.get(k).and_then(Value::as_u64).map(|x| (x as usize).min(n));
|
||||
let (a, c) = match (g("start").or(g("anchor")), g("end").or(g("caret"))) {
|
||||
(Some(a), Some(c)) => (a, c),
|
||||
(Some(a), None) | (None, Some(a)) => (a, a),
|
||||
(None, None) => match str_p(p, "select") {
|
||||
Some("all") => (0, n),
|
||||
_ => return Err(bad("text.setSelection", "give start/end (or anchor/caret), or select: \"all\"")),
|
||||
},
|
||||
};
|
||||
if (e.anchor, e.caret) != (a, c) {
|
||||
e.pending = None;
|
||||
}
|
||||
e.anchor = a;
|
||||
e.caret = c;
|
||||
e.goal_x = None;
|
||||
s.state.text_edit = Some(e);
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Caret targets for `text.moveCaret`.
|
||||
fn move_target(s: &Session, e: &TextEdit, doc: &TextDoc, to: &str) -> Result<(usize, Option<f64>)> {
|
||||
let n = doc.char_len();
|
||||
let (lo, hi) = (e.range().start, e.range().end);
|
||||
let collapse = !e.is_empty();
|
||||
let lay = || effectcraft_text::layout_doc(doc);
|
||||
Ok(match to {
|
||||
"left" if collapse => (lo, None),
|
||||
"right" if collapse => (hi, None),
|
||||
"left" => (e.caret.saturating_sub(1), None),
|
||||
"right" => ((e.caret + 1).min(n), None),
|
||||
"wordLeft" => (effectcraft_keyframe::text_doc::word_left(&doc.text, e.caret), None),
|
||||
"wordRight" => (effectcraft_keyframe::text_doc::word_right(&doc.text, e.caret), None),
|
||||
"lineStart" | "home" => (lay().line_span(e.caret).0, None),
|
||||
"lineEnd" => (lay().line_span(e.caret).1, None),
|
||||
"up" | "down" => {
|
||||
let (c, g) = lay().move_vertical(e.caret, if to == "up" { -1 } else { 1 }, e.goal_x);
|
||||
(c, Some(g))
|
||||
}
|
||||
"paraStart" | "paraUp" => {
|
||||
let r = &doc.para_ranges()[doc.para_of(e.caret)];
|
||||
let pi = doc.para_of(e.caret);
|
||||
if e.caret == r.start && pi > 0 { (doc.para_ranges()[pi - 1].start, None) } else { (r.start, None) }
|
||||
}
|
||||
"paraEnd" | "paraDown" => {
|
||||
let ranges = doc.para_ranges();
|
||||
let pi = doc.para_of(e.caret);
|
||||
if e.caret == ranges[pi].end && pi + 1 < ranges.len() { (ranges[pi + 1].end, None) } else { (ranges[pi].end, None) }
|
||||
}
|
||||
"start" => (0, None),
|
||||
"end" | "docEnd" => (n, None),
|
||||
_ => {
|
||||
let _ = s;
|
||||
return Err(bad("text.moveCaret", format!("unknown target {to}")));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.moveCaret")?;
|
||||
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.moveCaret", "not a text layer"))?;
|
||||
let e = session_on(s, lid);
|
||||
let to =
|
||||
str_p(p, "to").ok_or_else(|| bad("text.moveCaret", "give to: left|right|wordLeft|wordRight|lineStart|lineEnd|up|down|paraStart|paraEnd|start|end"))?;
|
||||
let extend = b_p(p, "extend").unwrap_or(false);
|
||||
// Extending moves the caret itself, not the collapsed selection edge.
|
||||
let probe = if extend { TextEdit { anchor: e.caret, ..e.clone() } } else { e.clone() };
|
||||
let (c, goal) = move_target(s, &probe, &doc, to)?;
|
||||
let mut e = e;
|
||||
e.caret = c;
|
||||
if !extend {
|
||||
e.anchor = c;
|
||||
}
|
||||
e.goal_x = goal;
|
||||
e.pending = None;
|
||||
s.state.text_edit = Some(e);
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Replace the selection (or `range`) with `text`; the caret goes after it.
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.insert")?;
|
||||
let text = str_p(p, "text").ok_or_else(|| bad("text.insert", "give text"))?.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let e = session_on(s, lid);
|
||||
let r = range_p(p).unwrap_or_else(|| e.range());
|
||||
let pending = e.pending.clone();
|
||||
let merge = str_p(p, "merge").map(str::to_string);
|
||||
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
||||
let n = doc.char_len();
|
||||
let r = r.start.min(n)..r.end.min(n);
|
||||
doc.replace_range(r.clone(), &text, pending.as_ref());
|
||||
let c = r.start + text.chars().count();
|
||||
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
||||
ed.anchor = c;
|
||||
ed.caret = c;
|
||||
ed.pending = None;
|
||||
ed.goal_x = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Delete the selection, or one character / word before (backward) or after the caret.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.delete")?;
|
||||
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.delete", "not a text layer"))?;
|
||||
let e = session_on(s, lid);
|
||||
let n = doc.char_len();
|
||||
let forward = str_p(p, "direction") == Some("forward");
|
||||
let word = b_p(p, "word").unwrap_or(false);
|
||||
let r = match range_p(p) {
|
||||
Some(r) => r.start.min(n)..r.end.min(n),
|
||||
None if !e.is_empty() => e.range(),
|
||||
None if forward => e.caret..if word { effectcraft_keyframe::text_doc::word_right(&doc.text, e.caret) } else { (e.caret + 1).min(n) },
|
||||
None => (if word { effectcraft_keyframe::text_doc::word_left(&doc.text, e.caret) } else { e.caret.saturating_sub(1) })..e.caret,
|
||||
};
|
||||
if r.is_empty() {
|
||||
return Ok(state_json(s));
|
||||
}
|
||||
let merge = str_p(p, "merge").map(str::to_string);
|
||||
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
||||
doc.replace_range(r.clone(), "", None);
|
||||
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
||||
ed.anchor = r.start;
|
||||
ed.caret = r.start;
|
||||
ed.pending = None;
|
||||
ed.goal_x = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Selected text (plain) of the session, and its formatted fragment.
|
||||
fn selection_doc(s: &Session) -> Option<TextDoc> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
let doc = layer_doc(s, e.layer)?;
|
||||
(!e.is_empty()).then(|| doc.slice(e.range()))
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy while editing text.
|
||||
pub(crate) fn copy(s: &mut Session) -> Result<Value> {
|
||||
let frag = selection_doc(s).ok_or_else(|| bad("edit.copy", "select some text first"))?;
|
||||
let text = frag.text.clone();
|
||||
s.state.text_clipboard = Some(frag);
|
||||
Ok(json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Cut while editing text.
|
||||
pub(crate) fn cut(s: &mut Session) -> Result<Value> {
|
||||
let r = copy(s)?;
|
||||
delete(s, &json!({}))?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste while editing text: the copied text with its formatting, or `text` (from the
|
||||
/// system clipboard) in the caret's style when it's something else.
|
||||
pub(crate) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.clone() else { return Err(bad("edit.paste", "not editing text")) };
|
||||
let clip = s.state.text_clipboard.clone();
|
||||
match (str_p(p, "text"), clip) {
|
||||
(Some(t), Some(c)) if t.replace("\r\n", "\n") != c.text => insert(s, &json!({"text": t})),
|
||||
(_, Some(c)) => {
|
||||
let r = e.range();
|
||||
edit_doc(s, "Paste", None, e.layer, |doc, st| {
|
||||
doc.insert_doc(r.clone(), &c);
|
||||
let at = r.start + c.char_len();
|
||||
if let Some(ed) = st.text_edit.as_mut() {
|
||||
ed.anchor = at;
|
||||
ed.caret = at;
|
||||
ed.pending = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
(Some(t), None) => insert(s, &json!({"text": t})),
|
||||
(None, None) => Err(bad("edit.paste", "nothing to paste")),
|
||||
}
|
||||
}
|
||||
|
||||
fn editing_or_text_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.text_edit.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
super::has_comp(s)?;
|
||||
let comp = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().any(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("edit text or select a text layer".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_text_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
editing_or_text_layer(s)?;
|
||||
if s.state.text_clipboard.is_some() { Ok(()) } else { Err("copy some text first".into()) }
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste Text and Match Formatting: the clipboard's plain text in the style at the caret.
|
||||
fn paste_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = match str_p(p, "text") {
|
||||
Some(t) => t.to_string(),
|
||||
None => s.state.text_clipboard.as_ref().map(|c| c.text.clone()).ok_or_else(|| bad("edit.pasteTextMatchFormatting", "nothing to paste"))?,
|
||||
};
|
||||
if s.state.text_edit.is_none() {
|
||||
// Not editing: replace the selected text layers' text, keeping their formatting.
|
||||
let lid = target(s, p, "edit.pasteTextMatchFormatting")?;
|
||||
edit_doc(s, "Paste Text and Match Formatting", None, lid, |doc, _| {
|
||||
doc.set_text(&text);
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let e = session_on(s, target(s, p, "edit.pasteTextMatchFormatting")?);
|
||||
let doc = layer_doc(s, e.layer).ok_or_else(|| bad("edit.pasteTextMatchFormatting", "not a text layer"))?;
|
||||
let style = e.pending.clone().unwrap_or_else(|| if e.is_empty() { doc.insertion_style(e.caret) } else { doc.style_at(e.range().start) });
|
||||
let r = e.range();
|
||||
edit_doc(s, "Paste Text and Match Formatting", None, e.layer, |doc, st| {
|
||||
doc.replace_range(r.clone(), &text, Some(&style));
|
||||
let at = r.start + text.chars().count();
|
||||
if let Some(ed) = st.text_edit.as_mut() {
|
||||
ed.anchor = at;
|
||||
ed.caret = at;
|
||||
ed.pending = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste Text Formatting Only: the copied text's character style (its first character)
|
||||
/// and paragraph settings on the selected text, or on the whole of the selected text layers.
|
||||
fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let clip = s.state.text_clipboard.clone().ok_or_else(|| bad("edit.pasteTextFormattingOnly", "copy some text first"))?;
|
||||
let style = clip.style_at(0);
|
||||
let para = clip.para(0);
|
||||
let targets: Vec<(LayerId, Option<std::ops::Range<usize>>)> = match &s.state.text_edit {
|
||||
Some(e) => vec![(e.layer, (!e.is_empty()).then(|| e.range()))],
|
||||
None => {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let ids: Vec<LayerId> = match p.get("layer") {
|
||||
Some(_) => vec![target(s, p, "edit.pasteTextFormattingOnly")?],
|
||||
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))).collect(),
|
||||
};
|
||||
ids.into_iter().map(|l| (l, None)).collect()
|
||||
}
|
||||
};
|
||||
if let Some(e) = s.state.text_edit.as_mut()
|
||||
&& e.is_empty()
|
||||
{
|
||||
// Caret only: the next typed text takes the formatting.
|
||||
e.pending = Some(style);
|
||||
return Ok(state_json(s));
|
||||
}
|
||||
for (lid, r) in targets {
|
||||
edit_doc(s, "Paste Text Formatting Only", None, lid, |doc, _| {
|
||||
let n = doc.char_len();
|
||||
let r = r.clone().unwrap_or(0..n.max(1));
|
||||
doc.apply_style(r.clone(), |st| *st = style.clone());
|
||||
doc.apply_para(r, |pp| *pp = para.clone());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
|
||||
/// layer is gone or not in the active comp.
|
||||
pub(crate) fn sanitize(s: &mut Session) {
|
||||
let Some(e) = s.state.text_edit.clone() else { return };
|
||||
match layer_doc(s, e.layer) {
|
||||
Some(d) => {
|
||||
let n = d.char_len();
|
||||
if e.anchor > n || e.caret > n {
|
||||
let ed = s.state.text_edit.as_mut().expect("checked");
|
||||
ed.anchor = ed.anchor.min(n);
|
||||
ed.caret = ed.caret.min(n);
|
||||
}
|
||||
}
|
||||
None => s.state.text_edit = None,
|
||||
}
|
||||
}
|
||||
|
||||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"text.edit",
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, select?: [anchor, caret]|\"none\", caret?, created?} → {layer, anchor, caret}",
|
||||
editing_or_text_layer,
|
||||
edit
|
||||
),
|
||||
cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit),
|
||||
cmd!(
|
||||
"text.setSelection",
|
||||
"Set Text Selection",
|
||||
[],
|
||||
None,
|
||||
"{layer?, start, end?} | {anchor, caret} | {select: \"all\"} (character indices)",
|
||||
editing_or_text_layer,
|
||||
set_selection
|
||||
),
|
||||
cmd!(
|
||||
"text.moveCaret",
|
||||
"Move Text Caret",
|
||||
[],
|
||||
None,
|
||||
"{to: left|right|wordLeft|wordRight|lineStart|lineEnd|up|down|paraStart|paraEnd|start|end, extend?}",
|
||||
editing,
|
||||
move_caret
|
||||
),
|
||||
cmd!("text.insert", "Type Text", [], None, "{layer?, text, range?: [start, end], merge?} (replaces the selection)", editing_or_text_layer, insert),
|
||||
cmd!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, direction?: backward|forward, word?, range?: [start, end], merge?}",
|
||||
editing_or_text_layer,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteTextMatchFormatting",
|
||||
"Paste Text and Match Formatting",
|
||||
["Edit"],
|
||||
None,
|
||||
"{text?} (the clipboard's text in the style at the caret)",
|
||||
editing_or_text_layer,
|
||||
paste_match
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteTextFormattingOnly",
|
||||
"Paste Text Formatting Only",
|
||||
["Edit"],
|
||||
None,
|
||||
"{layer?} (the copied text's formatting on the selected text or text layers)",
|
||||
has_text_clip,
|
||||
paste_formatting
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a layer is being edited (for the Character / Paragraph panels).
|
||||
pub fn editing_layer(s: &Session) -> Option<LayerId> {
|
||||
s.state.text_edit.as_ref().map(|e| e.layer)
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
//! Composition viewer state that agents and the CLI can drive too: snapping, Show Channel and
|
||||
//! exposure, snapshots, Fast Previews, guides dragged out of the rulers, and the Pen tool for
|
||||
//! shape layers (`shape.newPath`).
|
||||
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
use effectcraft_project::LayerSource;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
|
||||
use crate::viewer::{Channel, Snapshot};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
/// Composition ▸ Preview ▸ Fast Previews modes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FastPreviews {
|
||||
#[default]
|
||||
Off,
|
||||
AdaptiveResolution,
|
||||
Draft,
|
||||
FastDraft,
|
||||
Wireframe,
|
||||
}
|
||||
|
||||
impl FastPreviews {
|
||||
pub const ALL: [FastPreviews; 5] =
|
||||
[FastPreviews::Off, FastPreviews::AdaptiveResolution, FastPreviews::Draft, FastPreviews::FastDraft, FastPreviews::Wireframe];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FastPreviews::Off => "off",
|
||||
FastPreviews::AdaptiveResolution => "adaptive",
|
||||
FastPreviews::Draft => "draft",
|
||||
FastPreviews::FastDraft => "fastDraft",
|
||||
FastPreviews::Wireframe => "wireframe",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FastPreviews::Off => "Off (Final Quality)",
|
||||
FastPreviews::AdaptiveResolution => "Adaptive Resolution",
|
||||
FastPreviews::Draft => "Draft",
|
||||
FastPreviews::FastDraft => "Fast Draft",
|
||||
FastPreviews::Wireframe => "Wireframe",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<FastPreviews> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
FastPreviews::ALL.into_iter().find(|m| m.id().to_ascii_lowercase() == n || m.label().to_ascii_lowercase().replace(' ', "").starts_with(&n))
|
||||
}
|
||||
/// Render options for the viewer: (draft quality, resolution factor while interacting).
|
||||
pub fn render(self, interacting: bool) -> (bool, f64) {
|
||||
match self {
|
||||
FastPreviews::Off => (false, 1.0),
|
||||
FastPreviews::AdaptiveResolution => (false, if interacting { 0.25 } else { 1.0 }),
|
||||
FastPreviews::Draft => (true, 1.0),
|
||||
FastPreviews::FastDraft => (true, if interacting { 0.25 } else { 1.0 }),
|
||||
FastPreviews::Wireframe => (true, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ViewOptions {
|
||||
pub channel: Channel,
|
||||
/// Show Channel ▸ Colorize: single channels shown in their colour instead of gray.
|
||||
pub colorized: bool,
|
||||
/// Adjust Exposure, in stops.
|
||||
pub exposure: f32,
|
||||
/// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame.
|
||||
pub show_snapshot: bool,
|
||||
pub fast_previews: FastPreviews,
|
||||
}
|
||||
|
||||
fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.snapping = b_p(p, "value").unwrap_or(!s.state.snapping);
|
||||
Ok(json!(s.state.snapping))
|
||||
}
|
||||
|
||||
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "channel").unwrap_or("rgb");
|
||||
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
|
||||
// Choosing the active single channel again returns to RGB, as in After Effects.
|
||||
let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c };
|
||||
s.state.viewer.channel = c;
|
||||
if let Some(v) = b_p(p, "colorized") {
|
||||
s.state.viewer.colorized = v;
|
||||
}
|
||||
Ok(json!({"channel": c.id(), "colorized": s.state.viewer.colorized}))
|
||||
}
|
||||
|
||||
fn exposure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.state.viewer.exposure as f64;
|
||||
let v = match (f_p(p, "stops"), f_p(p, "delta")) {
|
||||
(Some(v), _) => v,
|
||||
(None, Some(d)) => cur + d,
|
||||
_ => return Err(bad("view.exposure", "pass `stops` or `delta`")),
|
||||
};
|
||||
s.state.viewer.exposure = v.clamp(-40.0, 40.0) as f32;
|
||||
Ok(json!(s.state.viewer.exposure))
|
||||
}
|
||||
|
||||
fn reset_exposure(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.state.viewer.exposure = 0.0;
|
||||
Ok(json!(0.0))
|
||||
}
|
||||
|
||||
fn take_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = s.time_of(cid);
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0);
|
||||
let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() });
|
||||
let (w, h) = (img.width, img.height);
|
||||
s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) });
|
||||
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
|
||||
Ok(json!({"width": w, "height": h, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
fn has_snapshot(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.snapshot.is_some() { Ok(()) } else { Err("take a snapshot first (Shift+F5)".into()) }
|
||||
}
|
||||
|
||||
fn show_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.viewer.show_snapshot = b_p(p, "value").unwrap_or(!s.state.viewer.show_snapshot);
|
||||
Ok(json!(s.state.viewer.show_snapshot))
|
||||
}
|
||||
|
||||
fn fast_previews(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "mode").unwrap_or("off");
|
||||
let m =
|
||||
FastPreviews::from_name(name).ok_or_else(|| bad("view.fastPreviewMode", format!("unknown mode `{name}` (off|adaptive|draft|fastDraft|wireframe)")))?;
|
||||
s.state.viewer.fast_previews = m;
|
||||
Ok(json!(m.id()))
|
||||
}
|
||||
|
||||
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, usize)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len();
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `index`"))? as usize;
|
||||
if i >= n {
|
||||
return Err(bad(cmd, format!("no guide {i} (the comp has {n})")));
|
||||
}
|
||||
Ok((cid, i))
|
||||
}
|
||||
|
||||
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.moveGuide")?;
|
||||
let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?;
|
||||
s.edit("Move Guide", merge_p(p), |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(pos))
|
||||
}
|
||||
|
||||
fn remove_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.removeGuide")?;
|
||||
s.edit("Remove Guide", None, |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] {
|
||||
match v {
|
||||
Some(Value::Array(a)) => {
|
||||
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
|
||||
[g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)]
|
||||
}
|
||||
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d),
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on
|
||||
/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are
|
||||
/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer).
|
||||
fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let mut v = pts(p.get("vertices"));
|
||||
if v.is_empty() {
|
||||
return Err(bad("shape.newPath", "need `vertices` [[x,y], …]"));
|
||||
}
|
||||
let n = v.len();
|
||||
let mut ins = pts(p.get("inTangents"));
|
||||
let mut outs = pts(p.get("outTangents"));
|
||||
ins.resize(n, [0.0; 2]);
|
||||
outs.resize(n, [0.0; 2]);
|
||||
let target = match p.get("layer") {
|
||||
Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?),
|
||||
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
|
||||
};
|
||||
if let Some(l) = target.and_then(|l| comp.layer(l))
|
||||
&& !matches!(l.source, LayerSource::Shape)
|
||||
{
|
||||
return Err(bad("shape.newPath", "the layer is not a shape layer"));
|
||||
}
|
||||
let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none());
|
||||
if comp_space {
|
||||
// Comp → layer space of the target (a new layer sits at the comp centre, unrotated).
|
||||
let inv = match target.and_then(|l| comp.layer(l)) {
|
||||
Some(l) => {
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid));
|
||||
ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
}
|
||||
None => effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-(comp.width as f64) / 2.0, -(comp.height as f64) / 2.0)),
|
||||
};
|
||||
for q in &mut v {
|
||||
let r = inv.apply(effectcraft_geom::vec2(q[0], q[1]));
|
||||
*q = [r.x, r.y];
|
||||
}
|
||||
for t in ins.iter_mut().chain(outs.iter_mut()) {
|
||||
let r = inv.apply_vec(effectcraft_geom::vec2(t[0], t[1]));
|
||||
*t = [r.x, r.y];
|
||||
}
|
||||
}
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
|
||||
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
|
||||
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
|
||||
let name = str_p(p, "name").map(str::to_string);
|
||||
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
|
||||
let lid = match target {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
|
||||
let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1));
|
||||
let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None);
|
||||
super::layer::insert_layer(proj, st, cid, l)?
|
||||
}
|
||||
};
|
||||
let mut next = proj.next_id;
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?;
|
||||
let k = contents.groups().filter(|g| g.match_id == "group").count();
|
||||
let mut ids = Ids(&mut next);
|
||||
let pg = build::shape_path(&mut ids, path);
|
||||
let path_uid = pg.uid;
|
||||
let mut items = vec![pg];
|
||||
if let Some(c) = stroke {
|
||||
items.push(build::shape_stroke(&mut ids, c, width));
|
||||
}
|
||||
if let Some(c) = fill {
|
||||
items.push(build::shape_fill(&mut ids, c));
|
||||
}
|
||||
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
|
||||
let group = g.uid;
|
||||
contents.children.insert(0, g.into());
|
||||
proj.next_id = next;
|
||||
st.selected_layers = vec![lid];
|
||||
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
|
||||
Ok((lid, group, path_uid))
|
||||
})?;
|
||||
Ok(json!({"layer": lid.0, "group": group, "path": path_uid}))
|
||||
}
|
||||
|
||||
/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`).
|
||||
pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let snap = s.snapshot.as_ref()?;
|
||||
let img = &snap.image;
|
||||
let mut out = Vec::with_capacity(img.data.len() * 4);
|
||||
for p in &img.data {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 };
|
||||
out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8));
|
||||
}
|
||||
Some((img.width, img.height, out))
|
||||
}
|
||||
|
||||
fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
|
||||
cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel),
|
||||
cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure),
|
||||
cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure),
|
||||
cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot),
|
||||
cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot),
|
||||
cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews),
|
||||
cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide),
|
||||
cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide),
|
||||
cmd!(
|
||||
"shape.newPath",
|
||||
"Pen Tool (Shape Path)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}",
|
||||
shape_layer_or_none,
|
||||
new_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ pub mod render_queue;
|
|||
mod session_settings;
|
||||
pub mod shortcuts;
|
||||
pub mod tracking;
|
||||
pub mod viewer;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ pub use effectcraft_geom as geom;
|
|||
pub use effectcraft_keyframe as keyframe;
|
||||
pub use effectcraft_project as project;
|
||||
pub use effectcraft_render as render;
|
||||
pub use effectcraft_text as text;
|
||||
pub use effectcraft_time as time;
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
|
||||
|
|
@ -178,6 +180,15 @@ pub struct EditorState {
|
|||
/// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines).
|
||||
#[serde(default)]
|
||||
pub hide_locked_masks: bool,
|
||||
/// On-canvas text editing (Type tool): the edited layer, selection and insertion style.
|
||||
#[serde(default)]
|
||||
pub text_edit: Option<commands::text_edit::TextEdit>,
|
||||
/// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only.
|
||||
#[serde(skip)]
|
||||
pub text_clipboard: Option<effectcraft_keyframe::TextDoc>,
|
||||
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[serde(default)]
|
||||
pub viewer: commands::viewer_cmds::ViewOptions,
|
||||
}
|
||||
|
||||
fn one_view() -> u8 {
|
||||
|
|
@ -254,6 +265,8 @@ pub struct Session {
|
|||
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
|
||||
/// Auto-save bookkeeping.
|
||||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -283,6 +296,7 @@ impl Default for Session {
|
|||
ui_commands: vec![],
|
||||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,6 +408,7 @@ impl Session {
|
|||
self.state.current_track = None;
|
||||
}
|
||||
self.state.project_selection.retain(|i| p.item(*i).is_some());
|
||||
commands::text_edit::sanitize(self);
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
|
@ -407,6 +422,11 @@ impl Session {
|
|||
self.state.active_comp.filter(|c| self.project.comp(*c).is_some())
|
||||
}
|
||||
|
||||
/// Current time of a comp.
|
||||
pub fn time_of(&self, comp: ItemId) -> Tick {
|
||||
self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO)
|
||||
}
|
||||
|
||||
/// Current time of the active comp.
|
||||
pub fn time(&self) -> Tick {
|
||||
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
|
||||
|
|
@ -518,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;
|
||||
|
|
@ -534,9 +560,13 @@ mod tests_styles;
|
|||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
#[cfg(test)]
|
||||
mod tests_text_edit;
|
||||
#[cfg(test)]
|
||||
mod tests_timeline;
|
||||
#[cfg(test)]
|
||||
mod tests_track;
|
||||
#[cfg(test)]
|
||||
mod tests_viewer;
|
||||
|
||||
/// Font families available to text layers (bundled + scanned system fonts).
|
||||
/// Text animation presets: (id, name), for menus.
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
|||
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
|
||||
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
|
||||
"view.shareViewOptions" => Some(s.state.share_view_options),
|
||||
"view.snapping" => Some(s.state.snapping),
|
||||
"layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => {
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur};
|
||||
let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None };
|
||||
|
|
@ -869,6 +870,7 @@ View
|
|||
---
|
||||
Show Grid | view.grid
|
||||
Snap to Grid | view.snapToGrid
|
||||
Snapping | view.snapping
|
||||
---
|
||||
View Options... | view.options
|
||||
Show Layer Controls | view.layerControls
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value> = (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());
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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, "");
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
//! On-canvas text editing and per-character styles through commands: run-scoped
|
||||
//! `layer.setText`, text.insert / delete / setSelection / moveCaret, the text clipboard and the
|
||||
//! paste-formatting commands, with undo / redo.
|
||||
|
||||
use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::text_edit::layer_doc;
|
||||
|
||||
fn session(text: &str) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let t = s.execute("layer.newText", json!({"text": text, "size": 60, "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, t)
|
||||
}
|
||||
|
||||
fn doc(s: &Session, t: u64) -> TextDoc {
|
||||
layer_doc(s, effectcraft_project::LayerId(t)).unwrap()
|
||||
}
|
||||
|
||||
fn sel(s: &Session) -> (usize, usize) {
|
||||
let e = s.state.text_edit.as_ref().unwrap();
|
||||
(e.anchor, e.caret)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_with_a_range_creates_runs_and_undoes() {
|
||||
let (mut s, t) = session("Hello World");
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [6, 11], "size": 30, "fill": "#ff0000", "kerning": "optical"})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.runs().len(), 2);
|
||||
assert_eq!(d.style_at(0).size, 60.0);
|
||||
assert_eq!(d.style_at(7).size, 30.0);
|
||||
assert_eq!(d.style_at(7).fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(d.style_at(7).kerning, Kerning::Optical);
|
||||
// Paragraph attributes apply to the paragraphs the range touches; document ones to all.
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [0, 1], "justify": "justifyAll", "indentFirst": 20, "spaceAfter": 6, "direction": "rtl"})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!((d.justify, d.indent_first, d.space_after), (Justify::JustifyAll, 20.0, 6.0));
|
||||
// Unknown attributes and bad values are errors (nothing changes).
|
||||
assert!(s.execute("layer.setText", json!({"layer": t, "sizee": 3})).is_err());
|
||||
assert!(s.execute("layer.setText", json!({"layer": t, "size": "big"})).is_err());
|
||||
assert!(s.execute("edit.undo", json!({})).is_ok());
|
||||
assert_eq!(doc(&s, t).justify, Justify::Left);
|
||||
assert!(s.execute("edit.undo", json!({})).is_ok());
|
||||
assert!(doc(&s, t).is_uniform());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).runs().len(), 2);
|
||||
// Without a range everything changes and the runs collapse again.
|
||||
s.execute("layer.setText", json!({"layer": t, "size": 44, "superscript": true})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert!(d.runs().iter().all(|r| r.style.size == 44.0 && r.style.baseline == BaselineOption::Superscript));
|
||||
// Replacing a range's text keeps the formatting around it.
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [0, 5], "text": "Howdy", "fauxBold": true})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "Howdy World");
|
||||
assert!(d.style_at(2).faux_bold && !d.style_at(7).faux_bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_deleting_and_caret_movement() {
|
||||
let (mut s, t) = session("abc def");
|
||||
s.execute("text.edit", json!({"layer": t})).unwrap();
|
||||
assert_eq!(sel(&s), (0, 7), "editing starts with everything selected");
|
||||
s.execute("text.setSelection", json!({"start": 3})).unwrap();
|
||||
s.execute("text.insert", json!({"text": "XY", "merge": "typing-1"})).unwrap();
|
||||
s.execute("text.insert", json!({"text": "Z", "merge": "typing-1"})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcXYZ def");
|
||||
assert_eq!(sel(&s), (6, 6));
|
||||
// One undo step for the merged typing.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abc def");
|
||||
assert_eq!(sel(&s), (6, 6));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("text.setSelection", json!({"start": 6})).unwrap();
|
||||
// Backspace / Delete, by character and by word.
|
||||
s.execute("text.delete", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcXY def");
|
||||
s.execute("text.delete", json!({"direction": "forward", "word": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcXY");
|
||||
s.execute("text.delete", json!({"word": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "");
|
||||
s.execute("text.insert", json!({"text": "one two\nthree"})).unwrap();
|
||||
assert_eq!(sel(&s), (13, 13));
|
||||
// Caret movement: words, lines, up / down, extend.
|
||||
let mv = |s: &mut Session, to: &str, extend: bool| -> (usize, usize) {
|
||||
let r = s.execute("text.moveCaret", json!({"to": to, "extend": extend})).unwrap();
|
||||
(r["anchor"].as_u64().unwrap() as usize, r["caret"].as_u64().unwrap() as usize)
|
||||
};
|
||||
assert_eq!(mv(&mut s, "lineStart", false), (8, 8));
|
||||
assert_eq!(mv(&mut s, "up", false).1, 0);
|
||||
assert_eq!(mv(&mut s, "wordRight", false), (3, 3));
|
||||
assert_eq!(mv(&mut s, "wordRight", true), (3, 7));
|
||||
assert_eq!(mv(&mut s, "left", false), (3, 3), "left collapses a selection to its start");
|
||||
assert_eq!(mv(&mut s, "lineEnd", false), (7, 7));
|
||||
assert_eq!(mv(&mut s, "down", false).1, 13);
|
||||
assert_eq!(mv(&mut s, "start", true), (13, 0));
|
||||
assert_eq!(mv(&mut s, "paraEnd", false), (7, 7));
|
||||
// Select All while editing selects the text, not the layers.
|
||||
s.execute("edit.selectAll", json!({})).unwrap();
|
||||
assert_eq!(sel(&s), (0, 13));
|
||||
// Undo past the insertion clamps the selection.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(sel(&s).0 <= doc(&s, t).char_len() && sel(&s).1 <= doc(&s, t).char_len());
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_style_applies_to_the_next_typed_text() {
|
||||
let (mut s, t) = session("ab");
|
||||
s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [1, 1], "size": 12, "fill": [0, 1, 0]})).unwrap();
|
||||
assert!(doc(&s, t).is_uniform(), "a caret-only change doesn't touch the text");
|
||||
s.execute("text.insert", json!({"text": "Q"})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "aQb");
|
||||
assert_eq!(d.style_at(1).size, 12.0);
|
||||
assert_eq!(d.style_at(1).fill, [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(d.style_at(2).size, 60.0);
|
||||
// Typing again continues the new style (the character before the caret).
|
||||
s.execute("text.insert", json!({"text": "R"})).unwrap();
|
||||
assert_eq!(doc(&s, t).style_at(2).size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_and_paste_formatting() {
|
||||
let (mut s, t) = session("red plain");
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "fill": "#ff0000", "size": 20})).unwrap();
|
||||
s.execute("text.edit", json!({"layer": t, "select": [0, 3]})).unwrap();
|
||||
let r = s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(r["text"], "red");
|
||||
// Paste keeps the copied formatting.
|
||||
s.execute("text.setSelection", json!({"start": 9})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "red plainred");
|
||||
assert_eq!(d.style_at(10).fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(sel(&s), (12, 12));
|
||||
// Different text from the system clipboard pastes in the caret's style.
|
||||
s.execute("text.setSelection", json!({"start": 4})).unwrap();
|
||||
s.execute("edit.paste", json!({"text": "new "})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "red new plainred");
|
||||
assert_eq!(doc(&s, t).style_at(5).size, 60.0);
|
||||
// Paste Text and Match Formatting: the copied text in the caret's style.
|
||||
s.execute("text.setSelection", json!({"start": 8})).unwrap();
|
||||
s.execute("edit.pasteTextMatchFormatting", json!({})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "red new redplainred");
|
||||
assert_eq!(d.style_at(9).fill, [1.0; 4]);
|
||||
// Paste Text Formatting Only onto a selection.
|
||||
s.execute("text.setSelection", json!({"start": 11, "end": 16})).unwrap();
|
||||
s.execute("edit.pasteTextFormattingOnly", json!({})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.style_at(12).fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(d.style_at(12).size, 20.0);
|
||||
// Cut removes the selection.
|
||||
s.execute("text.setSelection", json!({"start": 0, "end": 4})).unwrap();
|
||||
assert_eq!(s.execute("edit.cut", json!({})).unwrap()["text"], "red ");
|
||||
assert_eq!(doc(&s, t).text, "new redplainred");
|
||||
// Not editing: Paste Text Formatting Only restyles the whole selected text layer.
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [t]})).unwrap();
|
||||
s.execute("edit.pasteTextFormattingOnly", json!({})).unwrap();
|
||||
assert!(doc(&s, t).is_uniform());
|
||||
assert_eq!(doc(&s, t).size, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_layers_point_box_and_empty_cleanup() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
|
||||
// Leaving an untouched Type-tool layer deletes it.
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
|
||||
let d = doc(&s, b);
|
||||
assert_eq!(d.box_size, Some([300.0, 120.0]));
|
||||
assert_eq!(d.box_pos, [-150.0, -60.0]);
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.name, "Boxed", "named after its text");
|
||||
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
|
||||
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
// Resizing the box (layer space) is an attribute too.
|
||||
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
|
||||
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
|
||||
s.execute("layer.setText", json!({"layer": b, "vertical": true})).unwrap();
|
||||
assert!(doc(&s, b).vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_state_round_trips_through_serde() {
|
||||
let (mut s, t) = session("abc");
|
||||
s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap();
|
||||
let j = serde_json::to_value(&s.state).unwrap();
|
||||
let back: crate::EditorState = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back.text_edit, s.state.text_edit);
|
||||
}
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots,
|
||||
//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and
|
||||
//! the Graph Editor transform box. Every edit undoes.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap};
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
#[test]
|
||||
fn snap_picks_nearest_target_within_tolerance() {
|
||||
let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)];
|
||||
let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap();
|
||||
assert_eq!(s.delta, [-3.0, -1.0]);
|
||||
assert_eq!(s.at, [100.0, 100.0]);
|
||||
// Out of tolerance: nothing.
|
||||
assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none());
|
||||
// Of several sources, the one nearest a target snaps.
|
||||
let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap();
|
||||
assert_eq!(s.delta, [1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
|
||||
let t = [
|
||||
SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid),
|
||||
SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide),
|
||||
SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid),
|
||||
];
|
||||
// x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to
|
||||
// the horizontal one.
|
||||
let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap();
|
||||
assert_eq!(s.delta, [2.0, -3.0]);
|
||||
assert_eq!(s.hits.len(), 2);
|
||||
assert_eq!(s.hits[0].source, SnapSource::Guide);
|
||||
assert_eq!(s.at, [54.0, 20.0]);
|
||||
// A point at the same distance as a line beats it (it snaps both axes).
|
||||
let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))];
|
||||
let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap();
|
||||
assert_eq!(s.hits[0].kind, SnapKind::Point);
|
||||
assert_eq!(s.delta, [-2.0, 0.0]);
|
||||
// Equal point distances: lower priority value (vertex) wins over a layer feature.
|
||||
let t3 =
|
||||
[SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))];
|
||||
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||||
let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 };
|
||||
let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts);
|
||||
// Layer a's corner (270, 155) is a target; layer b (dragged) is not.
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0]));
|
||||
assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b))));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0]));
|
||||
// Dragging a corner near layer a's corner lands on it (edges snap x and y).
|
||||
let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap();
|
||||
assert_eq!(hit.at, [270.0, 155.0]);
|
||||
assert_eq!(hit.delta, [-2.0, -2.0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels, exposure, snapshot
|
||||
|
||||
#[test]
|
||||
fn channel_and_exposure_pixel_transforms() {
|
||||
// Premultiplied: straight (200, 100, 50) at 50% alpha.
|
||||
let px = [100u8, 50, 25, 128];
|
||||
let run = |c: Channel, col: bool, stops: f32| {
|
||||
let mut v = [px];
|
||||
display_transform(&mut v, c, col, stops);
|
||||
v[0]
|
||||
};
|
||||
assert_eq!(run(Channel::Rgb, false, 0.0), px);
|
||||
assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]);
|
||||
assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]);
|
||||
assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]);
|
||||
assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]);
|
||||
let st = run(Channel::RgbStraight, false, 0.0);
|
||||
assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}");
|
||||
// +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity.
|
||||
let mut g = [[128u8, 128, 128, 255]];
|
||||
display_transform(&mut g, Channel::Rgb, false, 1.0);
|
||||
assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]);
|
||||
let mut g = [[128u8, 128, 128, 255]];
|
||||
display_transform(&mut g, Channel::Rgb, false, -1.0);
|
||||
assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]);
|
||||
assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200);
|
||||
// Exposure applies to a single channel too, and keeps alpha untouched in RGB.
|
||||
assert!(run(Channel::Red, false, 1.0)[0] > 100);
|
||||
assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_exposure_and_fast_preview_commands() {
|
||||
let mut s = comp();
|
||||
s.execute("view.channel", json!({"channel": "alpha"})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::Alpha);
|
||||
s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::Rgb);
|
||||
s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::RgbStraight);
|
||||
assert!(s.state.viewer.colorized);
|
||||
s.execute("view.exposure", json!({"stops": 1.5})).unwrap();
|
||||
s.execute("view.exposure", json!({"delta": -0.5})).unwrap();
|
||||
assert_eq!(s.state.viewer.exposure, 1.0);
|
||||
s.execute("view.resetExposure", json!({})).unwrap();
|
||||
assert_eq!(s.state.viewer.exposure, 0.0);
|
||||
s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap();
|
||||
assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft);
|
||||
assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err());
|
||||
let was = s.state.snapping;
|
||||
s.execute("view.snapping", json!({})).unwrap();
|
||||
assert_eq!(s.state.snapping, !was);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_store_and_restore() {
|
||||
let mut s = comp();
|
||||
assert!(!s.is_enabled("view.showSnapshot"));
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap();
|
||||
let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
|
||||
// The project changes; the snapshot keeps the old frame.
|
||||
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap();
|
||||
let snap = s.snapshot.clone().unwrap();
|
||||
assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
|
||||
let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
|
||||
assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]);
|
||||
s.execute("view.showSnapshot", json!({"value": true})).unwrap();
|
||||
assert!(s.state.viewer.show_snapshot);
|
||||
let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap();
|
||||
assert_eq!((w, h), (160, 90));
|
||||
assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
|
||||
s.execute("edit.purge", json!({"what": "snapshot"})).unwrap();
|
||||
assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_move_and_remove_with_undo() {
|
||||
let mut s = comp();
|
||||
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap();
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0);
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0);
|
||||
s.execute("view.removeGuide", json!({"index": 0})).unwrap();
|
||||
assert!(s.active_comp().unwrap().guides.is_empty());
|
||||
assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err());
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().guides.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_of_interest_renders_and_crops() {
|
||||
let mut s = comp();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap();
|
||||
s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let full = s.render(cid, Tick::ZERO, Default::default());
|
||||
let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() });
|
||||
assert_eq!((roi.width, roi.height), (240, 160));
|
||||
for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] {
|
||||
assert_eq!(roi.get(x, y), full.get(x + 200, y + 100));
|
||||
}
|
||||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!((c.width, c.height), (240, 160));
|
||||
let cropped = s.render(cid, Tick::ZERO, Default::default());
|
||||
assert_eq!(cropped.get(30, 50), roi.get(30, 50));
|
||||
assert_eq!(cropped.get(30, 50)[3], 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shape pen and vertices
|
||||
|
||||
fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||||
let l = layer(s, lid);
|
||||
l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pen_builds_shape_group_and_renders() {
|
||||
let mut s = comp();
|
||||
// Nothing selected: a new shape layer; vertices in comp space.
|
||||
let r = s
|
||||
.execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0}))
|
||||
.unwrap();
|
||||
let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||||
let l = layer(&s, lid);
|
||||
assert!(matches!(l.source, effectcraft_project::LayerSource::Shape));
|
||||
let g = l.props.find_group(group).unwrap();
|
||||
assert_eq!(g.name, "Shape 1");
|
||||
let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||
assert_eq!(items, vec!["path", "fill"]);
|
||||
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1");
|
||||
// Layer space: the layer sits at the comp centre.
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]);
|
||||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||||
assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(img.get(50, 50)[3], 0.0);
|
||||
// With the shape layer selected the Pen adds a second group (with a stroke) to it.
|
||||
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap();
|
||||
assert_eq!(r2["layer"].as_u64(), Some(lid));
|
||||
let l = layer(&s, lid);
|
||||
let names: Vec<String> = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
|
||||
assert_eq!(names, vec!["Shape 2", "Shape 1"]);
|
||||
let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap();
|
||||
let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||
assert_eq!(items, vec!["path", "stroke", "fill"]);
|
||||
s.undo();
|
||||
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
|
||||
// Not a shape layer → error.
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
|
||||
let mut s = comp();
|
||||
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap();
|
||||
let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||||
// Pen continues the path: add a vertex, move it, close.
|
||||
s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap();
|
||||
s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap();
|
||||
let sp = shape_path(&s, lid, path);
|
||||
assert_eq!(sp.vertices.len(), 4);
|
||||
assert!(sp.closed);
|
||||
// Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint.
|
||||
let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap();
|
||||
assert_eq!(i.as_u64(), Some(1));
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]);
|
||||
// Convert Vertex: corner → smooth → corner.
|
||||
let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||||
assert_eq!(c["smooth"], json!(true));
|
||||
let sp = shape_path(&s, lid, path);
|
||||
// Neighbours (50,0) and (100,100): tangent along them, a sixth of the way.
|
||||
assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]);
|
||||
assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]);
|
||||
s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||||
s.undo();
|
||||
assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||||
// Delete Vertex (selection-based).
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap();
|
||||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 4);
|
||||
s.undo();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 5);
|
||||
// Free transform of the whole shape path (scale 200% about the bbox centre).
|
||||
s.execute("mask.selectVertices", json!({"vertices": []})).unwrap();
|
||||
s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]);
|
||||
// Masks work through the same commands.
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap();
|
||||
let l = layer(&s, sol);
|
||||
let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert_eq!(mp.vertices.len(), 4);
|
||||
assert_eq!(mp.vertices[3], [50.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_segment_keeps_curve_shape() {
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
let mut sp = ShapePath {
|
||||
vertices: vec![[0.0, 0.0], [100.0, 0.0]],
|
||||
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
|
||||
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
|
||||
closed: false,
|
||||
};
|
||||
let seg = |sp: &ShapePath, i: usize, t: f64| {
|
||||
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);
|
||||
let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]];
|
||||
let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]];
|
||||
let u = 1.0 - t;
|
||||
let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k];
|
||||
[f(0), f(1)]
|
||||
};
|
||||
let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect();
|
||||
crate::commands::split_segment_for_tests(&mut sp, 0, 0.3);
|
||||
assert_eq!(sp.vertices.len(), 3);
|
||||
for i in 0..2 {
|
||||
for j in 0..=50 {
|
||||
let p = seg(&sp, i, j as f64 / 50.0);
|
||||
let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min);
|
||||
assert!(d < 0.2, "{p:?} {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion paths and graph box
|
||||
|
||||
fn animated_position(s: &mut Session) -> u64 {
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] {
|
||||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap();
|
||||
}
|
||||
lid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_key_drag_edits_value_and_tangents() {
|
||||
let mut s = comp();
|
||||
let lid = animated_position(&mut s);
|
||||
// Dragging the middle key's dot sets that key's value (merged drag = one undo step).
|
||||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap();
|
||||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap();
|
||||
let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone();
|
||||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0]));
|
||||
assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0]));
|
||||
// Tangent drag: out handle set, in mirrored, no longer auto.
|
||||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap();
|
||||
let k = pos(&s).keys[1].clone();
|
||||
assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]);
|
||||
assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]);
|
||||
assert!(!k.spatial_auto);
|
||||
// The motion path passes through the key and bulges along the tangent.
|
||||
let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3();
|
||||
assert_eq!([mid[0], mid[1]], [320.0, 140.0]);
|
||||
// Alt-drag (break): only the in handle changes.
|
||||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap();
|
||||
let k = pos(&s).keys[1].clone();
|
||||
assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0]));
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert!(pos(&s).keys[1].spatial_auto);
|
||||
s.undo();
|
||||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_transform_box_scales_key_times_and_values() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid;
|
||||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
// Time: scale 2× about 0 s; value: scale 0.5 about 100.
|
||||
s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect();
|
||||
assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]);
|
||||
assert_eq!(s.state.selected_keys.len(), 3);
|
||||
// Alt-drag of the last key in the timeline: scale the group about the first key; times
|
||||
// land on frames.
|
||||
s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
let got: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(got, vec![0.0, 1.0, 2.0]);
|
||||
s.undo();
|
||||
s.undo();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::<Vec<_>>(), vec![0.0, 1.0, 2.0]);
|
||||
assert_eq!(p.keys[1].value.as_f64(), 50.0);
|
||||
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and
|
||||
//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot.
|
||||
//!
|
||||
//! Nothing here touches the project; edits go through commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_geom::vec2;
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
/// What a snap target is: a point (snaps both axes) or an axis-aligned line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SnapKind {
|
||||
Point,
|
||||
/// A vertical line at `pos[0]` (snaps x only).
|
||||
VLine,
|
||||
/// A horizontal line at `pos[1]` (snaps y only).
|
||||
HLine,
|
||||
}
|
||||
|
||||
/// Where a snap target comes from (for feedback: the highlighted box / cross).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SnapSource {
|
||||
Layer(LayerId),
|
||||
Comp,
|
||||
Guide,
|
||||
Grid,
|
||||
/// A mask or shape path vertex of a layer.
|
||||
Vertex(LayerId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SnapTarget {
|
||||
pub kind: SnapKind,
|
||||
/// Comp pixels.
|
||||
pub pos: [f64; 2],
|
||||
/// Lower wins when two targets are equally near (vertices and layer features before
|
||||
/// guides, guides before the grid).
|
||||
pub priority: u8,
|
||||
pub source: SnapSource,
|
||||
}
|
||||
|
||||
impl SnapTarget {
|
||||
pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::Point, pos, priority, source }
|
||||
}
|
||||
pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source }
|
||||
}
|
||||
pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Priorities of the standard targets.
|
||||
pub const PRI_VERTEX: u8 = 0;
|
||||
pub const PRI_LAYER: u8 = 1;
|
||||
pub const PRI_COMP: u8 = 2;
|
||||
pub const PRI_GUIDE: u8 = 3;
|
||||
pub const PRI_GRID: u8 = 4;
|
||||
|
||||
/// A snap: move the dragged feature by `delta` (comp pixels); `hits` are the targets it lands on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Snap {
|
||||
pub delta: [f64; 2],
|
||||
pub hits: Vec<SnapTarget>,
|
||||
/// The snapped feature position.
|
||||
pub at: [f64; 2],
|
||||
}
|
||||
|
||||
fn better(a: (f64, u8), b: (f64, u8)) -> bool {
|
||||
if (a.0 - b.0).abs() < 1e-6 { a.1 < b.1 } else { a.0 < b.0 }
|
||||
}
|
||||
|
||||
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
|
||||
/// A point target snaps both axes and wins when it is at least as near as the best lines;
|
||||
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
|
||||
/// the lower priority value.
|
||||
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
|
||||
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
for s in sources {
|
||||
for t in targets {
|
||||
let (d, slot) = match t.kind {
|
||||
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt),
|
||||
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x),
|
||||
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y),
|
||||
};
|
||||
if d > tol {
|
||||
continue;
|
||||
}
|
||||
let key = (d, t.priority);
|
||||
if slot.as_ref().is_none_or(|(k, _, _)| better(key, *k)) {
|
||||
*slot = Some((key, *s, *t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
|
||||
if let Some((k, s, t)) = best_pt
|
||||
&& k.0 <= line_best + 1e-9
|
||||
{
|
||||
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
|
||||
}
|
||||
if best_x.is_none() && best_y.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut delta = [0.0; 2];
|
||||
let mut hits = vec![];
|
||||
let mut at = [f64::NAN; 2];
|
||||
if let Some((_, s, t)) = best_x {
|
||||
delta[0] = t.pos[0] - s[0];
|
||||
at = [t.pos[0], s[1]];
|
||||
hits.push(t);
|
||||
}
|
||||
if let Some((_, s, t)) = best_y {
|
||||
delta[1] = t.pos[1] - s[1];
|
||||
at = if at[0].is_nan() { [s[0], t.pos[1]] } else { [at[0], t.pos[1]] };
|
||||
hits.push(t);
|
||||
}
|
||||
Some(Snap { delta, hits, at })
|
||||
}
|
||||
|
||||
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
|
||||
/// (2D layers; `None` for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let mut out = vec![];
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
if let Some(b) = effectcraft_render::content_bounds(ctx, layer) {
|
||||
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] {
|
||||
let q = m.apply(vec2(p[0], p[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
}
|
||||
if let Some(tr) = layer.transform() {
|
||||
let a = ctx.v3(layer, tr, "anchor", [0.0; 3]);
|
||||
let q = m.apply(vec2(a[0], a[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mask and shape-path vertices of a layer in comp space.
|
||||
pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
let mut out = vec![];
|
||||
if let Some(masks) = layer.masks() {
|
||||
for g in masks.groups() {
|
||||
if let Some(sp) = g.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) {
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = m.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uid, sp) in shape_paths(ctx, layer) {
|
||||
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY);
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = pm.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Shape-layer Path items (uid, evaluated path in the item's group space).
|
||||
pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyframe::ShapePath)> {
|
||||
let mut out = vec![];
|
||||
let Some(c) = layer.props.sub("contents") else { return out };
|
||||
fn walk(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, out: &mut Vec<(u64, effectcraft_keyframe::ShapePath)>) {
|
||||
for sub in g.groups() {
|
||||
if sub.match_id == "path"
|
||||
&& let Some(sp) = sub.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned())
|
||||
{
|
||||
out.push((sub.uid, sp));
|
||||
} else if sub.match_id == "group" || sub.match_id == "contents" {
|
||||
walk(ctx, layer, sub, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(ctx, layer, c, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Which target families are on.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SnapOptions {
|
||||
pub guides: bool,
|
||||
pub grid: bool,
|
||||
pub grid_spacing: f64,
|
||||
}
|
||||
|
||||
/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges
|
||||
/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are
|
||||
/// skipped.
|
||||
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let comp = ctx.comp;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let mut out = vec![
|
||||
SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::vline(w, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(h, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp),
|
||||
];
|
||||
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) {
|
||||
let f = layer_features(ctx, l);
|
||||
// Edges of unrotated layers are lines too.
|
||||
if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 {
|
||||
for x in [f[0][0], f[1][0]] {
|
||||
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
for y in [f[0][1], f[3][1]] {
|
||||
out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
}
|
||||
out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
}
|
||||
if opts.guides {
|
||||
for g in &comp.guides {
|
||||
out.push(if g.vertical {
|
||||
SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
} else {
|
||||
SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
});
|
||||
}
|
||||
}
|
||||
if opts.grid && opts.grid_spacing >= 1.0 {
|
||||
let s = opts.grid_spacing;
|
||||
let mut x = 0.0;
|
||||
while x <= w + 1e-9 && out.len() < 20_000 {
|
||||
out.push(SnapTarget::vline(x, PRI_GRID, SnapSource::Grid));
|
||||
x += s;
|
||||
}
|
||||
let mut y = 0.0;
|
||||
while y <= h + 1e-9 && out.len() < 40_000 {
|
||||
out.push(SnapTarget::hline(y, PRI_GRID, SnapSource::Grid));
|
||||
y += s;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels and exposure
|
||||
|
||||
/// Show Channel and Color Management Settings.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
Rgb,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Alpha,
|
||||
RgbStraight,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub const ALL: [Channel; 6] = [Channel::Rgb, Channel::Red, Channel::Green, Channel::Blue, Channel::Alpha, Channel::RgbStraight];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Rgb => "rgb",
|
||||
Channel::Red => "red",
|
||||
Channel::Green => "green",
|
||||
Channel::Blue => "blue",
|
||||
Channel::Alpha => "alpha",
|
||||
Channel::RgbStraight => "rgbStraight",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Rgb => "RGB",
|
||||
Channel::Red => "Red",
|
||||
Channel::Green => "Green",
|
||||
Channel::Blue => "Blue",
|
||||
Channel::Alpha => "Alpha",
|
||||
Channel::RgbStraight => "RGB Straight",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Channel> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
Channel::ALL.into_iter().find(|c| c.id().to_ascii_lowercase() == n || c.label().to_ascii_lowercase().replace(' ', "") == n)
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
fn linear_to_srgb(v: f32) -> f32 {
|
||||
let v = v.max(0.0);
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// 8-bit exposure lookup table: `stops` applied in linear light.
|
||||
pub fn exposure_lut(stops: f32) -> [u8; 256] {
|
||||
let k = 2f32.powf(stops);
|
||||
let mut lut = [0u8; 256];
|
||||
for (i, o) in lut.iter_mut().enumerate() {
|
||||
let lin = srgb_to_linear(i as f32 / 255.0) * k;
|
||||
*o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place.
|
||||
/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque
|
||||
/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the
|
||||
/// unpremultiplied colour, opaque.
|
||||
pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) {
|
||||
let lut = (stops != 0.0).then(|| exposure_lut(stops));
|
||||
let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v);
|
||||
for p in px.iter_mut() {
|
||||
let [r, g, b, a] = *p;
|
||||
*p = match channel {
|
||||
Channel::Rgb => {
|
||||
if lut.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Exposure on straight colour, re-premultiplied.
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8;
|
||||
[pm(r), pm(g), pm(b), a]
|
||||
}
|
||||
Channel::Red | Channel::Green | Channel::Blue => {
|
||||
let (i, c) = match channel {
|
||||
Channel::Red => (0, r),
|
||||
Channel::Green => (1, g),
|
||||
_ => (2, b),
|
||||
};
|
||||
let v = ex(c);
|
||||
if colorized {
|
||||
let mut o = [0, 0, 0, 255];
|
||||
o[i] = v;
|
||||
o
|
||||
} else {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
}
|
||||
Channel::Alpha => {
|
||||
let v = ex(a);
|
||||
[v, v, v, 255]
|
||||
}
|
||||
Channel::RgbStraight => {
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
[ex(un(r)), ex(un(g)), ex(un(b)), 255]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub comp: effectcraft_project::ItemId,
|
||||
pub time: Tick,
|
||||
/// Render scale the image was taken at.
|
||||
pub scale: f64,
|
||||
pub image: Arc<Image>,
|
||||
}
|
||||
|
|
@ -172,6 +172,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
|
|||
draft: job.settings.quality == RenderQuality::Draft,
|
||||
// Output renders always look through the comp's active camera.
|
||||
view: None,
|
||||
roi: None,
|
||||
};
|
||||
let mut r = Renderer::new(job.project, job.footage, opts);
|
||||
r.expr = job.expr;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ workspace = true
|
|||
effectcraft-time = { workspace = true }
|
||||
effectcraft-geom = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
effectcraft-path = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@ pub enum Req {
|
|||
SourceRect { comp: u64, layer: u64, t: Secs, extents: bool },
|
||||
/// `[[time, duration, comment, chapter, url], …]` for a layer, or the comp when `layer` is None.
|
||||
Markers { comp: u64, layer: Option<u64> },
|
||||
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
|
||||
/// `layer.setText` keys), or null for other properties.
|
||||
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
|
||||
}
|
||||
|
||||
/// A text document as the style API's JSON.
|
||||
pub fn doc_json(d: &effectcraft_keyframe::TextDoc) -> String {
|
||||
use effectcraft_keyframe::text_doc::{char_style_json, para_style_json};
|
||||
let runs: Vec<serde_json::Value> = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect();
|
||||
let paras: Vec<serde_json::Value> = d.paras().iter().map(para_style_json).collect();
|
||||
serde_json::json!({"doc": d, "text": d.text, "runs": runs, "paras": paras}).to_string()
|
||||
}
|
||||
|
||||
/// Plain data answer, converted to a JS value by the runtime.
|
||||
|
|
@ -292,6 +303,16 @@ impl<'a> Resolver<'a> {
|
|||
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
|
||||
value_resp(&v, prop_dims(l, p))
|
||||
}
|
||||
Req::Doc { comp, layer, path, t, pre } => {
|
||||
let (l, p) = self.prop(*comp, *layer, path)?;
|
||||
let t = f64::from_bits(*t);
|
||||
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
|
||||
match v {
|
||||
Value::Text(d) => Resp::Str(doc_json(&d)),
|
||||
Value::Str(s) => Resp::Str(doc_json(&effectcraft_keyframe::TextDoc::plain(&s))),
|
||||
_ => Resp::Null,
|
||||
}
|
||||
}
|
||||
Req::Xform { comp, layer, t } => {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
let ctx = self.ctx_at(*comp, f64::from_bits(*t))?;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ var textTotal = 1;
|
|||
var selectorValue = [100, 100, 100];
|
||||
|
||||
// Request ops (must match runtime.rs).
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9;
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
|
||||
|
||||
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
time = t;
|
||||
|
|
@ -34,6 +34,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
|||
function __finish(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
|
||||
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
|
||||
// host requests can still be answered and the script re-run.
|
||||
if (Array.isArray(r)) return r.map(function (x) { return __v(x); });
|
||||
|
|
@ -640,6 +641,18 @@ Prop.prototype = {
|
|||
inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); },
|
||||
outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); },
|
||||
isClosed: function () { return this.value.isClosed(); },
|
||||
// Source Text style API.
|
||||
__docAt: function (t) {
|
||||
var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self);
|
||||
if (d === null) __err('Property "' + this.name + '" is not a Source Text property');
|
||||
return JSON.parse(d);
|
||||
},
|
||||
get style() { return this.getStyleAt(0); },
|
||||
getStyleAt: function (i, t) {
|
||||
if (t === undefined) t = time;
|
||||
return new __TextStyle(this.__docAt(__v(t)), i === undefined ? 0 : Math.floor(__v(i)), [], false);
|
||||
},
|
||||
createStyle: function () { return new __TextStyle(this.__docAt(time), 0, [], true); },
|
||||
valueOf: function () { return this.value; },
|
||||
toString: function () { return String(this.value); },
|
||||
};
|
||||
|
|
@ -658,6 +671,138 @@ function __mkProp(c, l, path) {
|
|||
return new Proxy(new Prop(c, l, path), __propHandler);
|
||||
}
|
||||
|
||||
// Text styles (the Source Text style API) ------------------------------------------------------
|
||||
// A TextStyle is the source document (as JSON from the host), the character index it reads its
|
||||
// attributes at, and the setter calls made on it ([key, value, start, count]). Setters return
|
||||
// new objects; returning a style from a Source Text expression applies it.
|
||||
|
||||
function __TextStyle(doc, idx, ops, empty) { this.__d = doc; this.__i = idx; this.__ops = ops; this.__empty = empty; }
|
||||
|
||||
function __styleSetter(key, conv) {
|
||||
return function (v, start, count) {
|
||||
v = __v(v);
|
||||
if (conv) v = conv(v, this);
|
||||
var s = start === undefined ? -1 : Math.max(0, Math.floor(__v(start)));
|
||||
var n = count === undefined ? -1 : Math.max(0, Math.floor(__v(count)));
|
||||
return new __TextStyle(this.__d, this.__i, this.__ops.concat([[key, v, s, n]]), this.__empty);
|
||||
};
|
||||
}
|
||||
|
||||
function __colorIn(c) {
|
||||
if (typeof c === 'string') return c;
|
||||
return [c[0], c[1], c[2]];
|
||||
}
|
||||
|
||||
__TextStyle.prototype = {
|
||||
__isTextStyle: true,
|
||||
__base: function () {
|
||||
var runs = this.__d.runs, pos = 0, i = this.__i;
|
||||
for (var k = 0; k < runs.length; k++) {
|
||||
if (i < pos + runs[k].len || k === runs.length - 1) return runs[k].style;
|
||||
pos += runs[k].len;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
__para: function () {
|
||||
var t = this.text, p = 0;
|
||||
for (var k = 0; k < Math.min(this.__i, t.length); k++) if (t[k] === '\n' || t[k] === '\r') p++;
|
||||
var paras = this.__d.paras;
|
||||
return paras[Math.min(p, paras.length - 1)];
|
||||
},
|
||||
__get: function (key, para) {
|
||||
for (var k = this.__ops.length - 1; k >= 0; k--) {
|
||||
var o = this.__ops[k];
|
||||
if (o[0] !== key) continue;
|
||||
if (o[2] < 0 || (this.__i >= o[2] && (o[3] < 0 || this.__i < o[2] + o[3]))) return o[1];
|
||||
}
|
||||
return (para ? this.__para() : this.__base())[key];
|
||||
},
|
||||
get text() {
|
||||
var t = this.__d.text;
|
||||
for (var k = 0; k < this.__ops.length; k++) {
|
||||
var o = this.__ops[k];
|
||||
if (o[0] !== 'text') continue;
|
||||
if (o[2] < 0) t = String(o[1]);
|
||||
else t = t.slice(0, o[2]) + String(o[1]) + (o[3] < 0 ? '' : t.slice(o[2] + o[3]));
|
||||
}
|
||||
return t;
|
||||
},
|
||||
get font() { return this.__get('font'); },
|
||||
get fontSize() { return this.__get('size'); },
|
||||
get isFauxBold() { return this.__get('fauxBold'); },
|
||||
get isFauxItalic() { return this.__get('fauxItalic'); },
|
||||
get isAllCaps() { return this.__get('allCaps'); },
|
||||
get isSmallCaps() { return this.__get('smallCaps'); },
|
||||
get tracking() { return this.__get('tracking'); },
|
||||
get autoLeading() { return this.__get('leading') === 'auto'; },
|
||||
get leading() { var l = this.__get('leading'); return l === 'auto' ? this.fontSize * 1.2 : l; },
|
||||
get baselineShift() { return this.__get('baselineShift'); },
|
||||
get applyFill() { return this.__get('applyFill'); },
|
||||
get fillColor() { var c = this.__get('fill'); return [c[0], c[1], c[2]]; },
|
||||
get applyStroke() { return this.__get('applyStroke'); },
|
||||
get strokeColor() { var c = this.__get('stroke'); return [c[0], c[1], c[2]]; },
|
||||
get strokeWidth() { return this.__get('strokeWidth'); },
|
||||
get horizontalScaling() { return this.__get('hScale'); },
|
||||
get verticalScaling() { return this.__get('vScale'); },
|
||||
get tsume() { return this.__get('tsume'); },
|
||||
get baselineOption() { return this.__get('baseline'); },
|
||||
get isSuperscript() { return this.__get('baseline') === 'superscript'; },
|
||||
get isSubscript() { return this.__get('baseline') === 'subscript'; },
|
||||
get kerningType() { var k = this.__get('kerning'); return typeof k === 'number' ? 'manual' : k; },
|
||||
get kerning() { var k = this.__get('kerning'); return typeof k === 'number' ? k : 0; },
|
||||
get isLigature() { return this.__get('ligatures'); },
|
||||
get justification() { return this.__get('justify', true); },
|
||||
get firstLineIndent() { return this.__get('indentFirst', true); },
|
||||
get startIndent() { return this.__get('indentLeft', true); },
|
||||
get endIndent() { return this.__get('indentRight', true); },
|
||||
get spaceBefore() { return this.__get('spaceBefore', true); },
|
||||
get spaceAfter() { return this.__get('spaceAfter', true); },
|
||||
get direction() { return this.__get('direction', true); },
|
||||
get isEveryLineComposer() { return this.__get('composer', true) === 'everyLine'; },
|
||||
get isHangingRoman() { return this.__get('hangingPunctuation', true); },
|
||||
setText: __styleSetter('text', function (v) { return String(v); }),
|
||||
replaceText: __styleSetter('text', function (v) { return String(v); }),
|
||||
setFont: __styleSetter('font', function (v) { return String(v); }),
|
||||
setFontSize: __styleSetter('size'),
|
||||
setFauxBold: __styleSetter('fauxBold', Boolean),
|
||||
setFauxItalic: __styleSetter('fauxItalic', Boolean),
|
||||
setAllCaps: __styleSetter('allCaps', Boolean),
|
||||
setSmallCaps: __styleSetter('smallCaps', Boolean),
|
||||
setTracking: __styleSetter('tracking'),
|
||||
setLeading: __styleSetter('leading'),
|
||||
setAutoLeading: __styleSetter('leading', function (v, s) { return v ? 'auto' : s.leading; }),
|
||||
setBaselineShift: __styleSetter('baselineShift'),
|
||||
setApplyFill: __styleSetter('applyFill', Boolean),
|
||||
setFillColor: __styleSetter('fill', __colorIn),
|
||||
setApplyStroke: __styleSetter('applyStroke', Boolean),
|
||||
setStrokeColor: __styleSetter('stroke', __colorIn),
|
||||
setStrokeWidth: __styleSetter('strokeWidth'),
|
||||
setHorizontalScaling: __styleSetter('hScale'),
|
||||
setVerticalScaling: __styleSetter('vScale'),
|
||||
setTsume: __styleSetter('tsume'),
|
||||
setBaselineOption: __styleSetter('baseline', function (v) { return String(v).toLowerCase().replace('_baseline', ''); }),
|
||||
setSuperscript: __styleSetter('superscript', Boolean),
|
||||
setSubscript: __styleSetter('subscript', Boolean),
|
||||
setKerningType: __styleSetter('kerning', function (v) { v = String(v).toLowerCase(); return v === 'manual' ? 0 : v; }),
|
||||
setKerning: __styleSetter('kerning', Number),
|
||||
setLigature: __styleSetter('ligatures', Boolean),
|
||||
setJustification: __styleSetter('justify', function (v) { return String(v); }),
|
||||
setFirstLineIndent: __styleSetter('indentFirst'),
|
||||
setStartIndent: __styleSetter('indentLeft'),
|
||||
setEndIndent: __styleSetter('indentRight'),
|
||||
setSpaceBefore: __styleSetter('spaceBefore'),
|
||||
setSpaceAfter: __styleSetter('spaceAfter'),
|
||||
setDirection: __styleSetter('direction', function (v) { v = String(v).toLowerCase(); return v.indexOf('right') === 0 || v === 'rtl' ? 'rtl' : 'ltr'; }),
|
||||
setEveryLineComposer: __styleSetter('composer', function (v) { return v ? 'everyLine' : 'singleLine'; }),
|
||||
setHangingRoman: __styleSetter('hangingPunctuation', Boolean),
|
||||
__out: function () {
|
||||
return { __style: true, __json: JSON.stringify({ doc: this.__empty ? null : this.__d.doc, ops: this.__ops }) };
|
||||
},
|
||||
toString: function () { return this.text; },
|
||||
valueOf: function () { return this.text; },
|
||||
};
|
||||
Object.defineProperty(__TextStyle.prototype, 'length', { get: function () { return this.text.length; } });
|
||||
|
||||
// Markers ---------------------------------------------------------------------------------------
|
||||
|
||||
function Markers(c, l) { this.__c = c; this.__l = l; this.__m = null; }
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@ pub enum Out {
|
|||
Num(f64),
|
||||
Str(String),
|
||||
Arr(Vec<Out>),
|
||||
Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool },
|
||||
Path {
|
||||
points: Vec<[f64; 2]>,
|
||||
ins: Vec<[f64; 2]>,
|
||||
outs: Vec<[f64; 2]>,
|
||||
closed: bool,
|
||||
},
|
||||
/// A text style object: JSON `{doc: TextDoc | null, ops: [[key, value, start, count], …]}`.
|
||||
Style(String),
|
||||
Object(String),
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +138,13 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
let l = num(args, 2, ctx)?;
|
||||
Req::Markers { comp: id(args, 1, ctx)?, layer: (l >= 0.0).then_some(l as u64) }
|
||||
}
|
||||
10 => Req::Doc {
|
||||
comp: id(args, 1, ctx)?,
|
||||
layer: id(args, 2, ctx)?,
|
||||
path: string(args, 3, ctx)?,
|
||||
t: secs(num(args, 4, ctx)?),
|
||||
pre: arg(args, 5).to_boolean(),
|
||||
},
|
||||
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
|
||||
};
|
||||
let found = FRAMES.with(|f| {
|
||||
|
|
@ -278,6 +292,10 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
|||
}
|
||||
return Ok(Out::Arr(out));
|
||||
}
|
||||
if o.get(js_string!("__style"), ctx).map_err(err)?.to_boolean() {
|
||||
let j = o.get(js_string!("__json"), ctx).map_err(err)?;
|
||||
return Ok(Out::Style(j.as_string().map(|s| s.to_std_string_escaped()).unwrap_or_default()));
|
||||
}
|
||||
if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() {
|
||||
let pts = |name: JsString, ctx: &mut Context| -> Result<Vec<[f64; 2]>, String> {
|
||||
let v = o.get(name, ctx).map_err(err)?;
|
||||
|
|
@ -408,6 +426,9 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::<Vec<_>>().join(","),
|
||||
Out::Undefined | Out::Null => String::new(),
|
||||
Out::Path { .. } => "[object Path]".into(),
|
||||
Out::Style(j) => {
|
||||
serde_json::from_str::<serde_json::Value>(j).ok().map(|v| styled_doc(&v, &effectcraft_keyframe::TextDoc::default()).text).unwrap_or_default()
|
||||
}
|
||||
Out::Object(s) => s.clone(),
|
||||
}
|
||||
};
|
||||
|
|
@ -428,11 +449,17 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
}
|
||||
Value::Bool(_) => Value::Bool(truthy(out)),
|
||||
Value::Enum(_) => Value::Enum((finite(numeric(out).unwrap_or(1.0))? - 1.0).round().max(0.0) as u32),
|
||||
Value::Text(doc) => {
|
||||
let mut d = doc.clone();
|
||||
d.text = text(out);
|
||||
Value::Text(d)
|
||||
}
|
||||
Value::Text(doc) => match out {
|
||||
Out::Style(j) => {
|
||||
let v: serde_json::Value = serde_json::from_str(j).map_err(|e| format!("Error: bad text style: {e}"))?;
|
||||
Value::Text(Box::new(styled_doc(&v, doc)))
|
||||
}
|
||||
_ => {
|
||||
let mut d = doc.clone();
|
||||
d.set_text(&text(out));
|
||||
Value::Text(d)
|
||||
}
|
||||
},
|
||||
Value::Str(_) => Value::Str(text(out)),
|
||||
Value::Path(_) => match out {
|
||||
Out::Path { points, ins, outs, closed } => {
|
||||
|
|
@ -450,6 +477,48 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Apply a returned text style (`{doc, ops}`) to the property's own document `own`: the style's
|
||||
/// source document (another layer's, or this one's) gives the formatting, then the setter calls
|
||||
/// apply in order. Without `setText` the text stays `own`'s.
|
||||
pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> effectcraft_keyframe::TextDoc {
|
||||
use effectcraft_keyframe::TextDoc;
|
||||
let mut d = own.clone();
|
||||
if let Some(src) = v.get("doc").filter(|s| !s.is_null()).and_then(|s| serde_json::from_value::<TextDoc>(s.clone()).ok()) {
|
||||
if src.text == own.text {
|
||||
// The same text (this layer's own style, or an identical one): every run carries over.
|
||||
d = TextDoc { box_size: own.box_size, box_pos: own.box_pos, vertical: own.vertical, ..src };
|
||||
} else {
|
||||
let first = src.style_at(0);
|
||||
d.runs.clear();
|
||||
d.apply_style_all(|s| *s = first.clone());
|
||||
let p0 = src.para(0);
|
||||
d.set_paras(vec![p0; d.para_count()]);
|
||||
d.stroke_over_fill = src.stroke_over_fill;
|
||||
}
|
||||
}
|
||||
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
|
||||
let Some(key) = op.get(0).and_then(|k| k.as_str()) else { continue };
|
||||
let val = op.get(1).cloned().unwrap_or_default();
|
||||
let start = op.get(2).and_then(|x| x.as_i64()).unwrap_or(-1);
|
||||
let count = op.get(3).and_then(|x| x.as_i64()).unwrap_or(-1);
|
||||
let n = d.char_len();
|
||||
let range = (start >= 0).then(|| {
|
||||
let a = (start as usize).min(n);
|
||||
a..if count < 0 { n } else { (a + count as usize).min(n) }
|
||||
});
|
||||
if key == "text" {
|
||||
let s = val.as_str().unwrap_or_default();
|
||||
match range {
|
||||
Some(r) => d.replace_range(r, s, None),
|
||||
None => d.set_text(s),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let _ = d.set_attr(key, &val, range);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Number → string like JS (`3`, not `3.0`).
|
||||
fn js_number(x: f64) -> String {
|
||||
if x.is_finite() && x == x.trunc() && x.abs() < 1e21 { format!("{}", x as i64) } else { format!("{x}") }
|
||||
|
|
|
|||
|
|
@ -723,6 +723,60 @@ fn source_text_reading_and_string_methods() {
|
|||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_text_style_api() {
|
||||
let mut f = fx();
|
||||
let doc = |f: &Fx, e: &str| -> TextDoc {
|
||||
match f.ev(f.title, "text/sourceText", e, 0.0) {
|
||||
Ok(Value::Text(d)) => *d,
|
||||
r => panic!("{e}: {r:?}"),
|
||||
}
|
||||
};
|
||||
// Getters read the first character's style.
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fontSize", 0.0), 40.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.isAllCaps ? 1 : 0", 0.0), 0.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.autoLeading ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.leading", 0.0), 48.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fillColor[1]", 0.0), 1.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setFontSize(12).fontSize", 0.0), 12.0);
|
||||
// Setters chain and return a styled document.
|
||||
let d = doc(&f, "text.sourceText.style.setFontSize(80).setFillColor([1, 0, 0]).setAllCaps(true)");
|
||||
assert_eq!(d.text, "Hello");
|
||||
assert!(d.is_uniform());
|
||||
assert_eq!((d.size, d.fill, d.all_caps), (80.0, [1.0, 0.0, 0.0, 1.0], true));
|
||||
// Character ranges create runs; setText replaces the text.
|
||||
let d = doc(&f, "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)");
|
||||
assert_eq!(d.text, "Hi there");
|
||||
let sizes: Vec<(usize, f64, bool)> = d.runs().iter().map(|r| (r.len, r.style.size, r.style.faux_bold)).collect();
|
||||
assert_eq!(sizes, vec![(2, 40.0, true), (1, 40.0, false), (5, 20.0, false)]);
|
||||
// getStyleAt reads a styled document per character (and at a time).
|
||||
{
|
||||
let l = f.layer_mut(f.title);
|
||||
let p = l.props.prop_mut("text/sourceText").unwrap();
|
||||
let Value::Text(d) = &mut p.value else { panic!() };
|
||||
d.apply_style(1..3, |s| s.size = 99.0);
|
||||
}
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(2, 0).fontSize", 0.0), 99.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(4).fontSize", 0.0), 40.0);
|
||||
// Returning the own style keeps its runs; plain strings keep the first character's style.
|
||||
let d = doc(&f, "text.sourceText.style");
|
||||
assert_eq!(d.runs().len(), 3);
|
||||
let d = doc(&f, "'Bye'");
|
||||
assert_eq!((d.text.as_str(), d.size), ("Bye", 40.0));
|
||||
// Paragraph setters.
|
||||
let d = doc(&f, "text.sourceText.style.setJustification('CENTER_JUSTIFY').setStartIndent(12).setSpaceBefore(4).setEveryLineComposer(false)");
|
||||
assert_eq!(d.justify, effectcraft_keyframe::Justify::Center);
|
||||
assert_eq!((d.indent_left, d.space_before), (12.0, 4.0));
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setStartIndent(7).startIndent", 0.0), 7.0);
|
||||
// createStyle sets only what's given and keeps the existing runs.
|
||||
let d = doc(&f, "thisComp.layer('Title').text.sourceText.createStyle().setTsume(50).setBaselineOption('superscript')");
|
||||
assert_eq!(d.text, "Hello");
|
||||
assert!(d.runs().iter().all(|r| r.style.tsume == 50.0 && r.style.baseline == effectcraft_keyframe::BaselineOption::Superscript));
|
||||
assert_eq!(d.runs().len(), 3);
|
||||
// Non-text properties have no style.
|
||||
assert!(f.ev(f.a, ROT, "transform.rotation.style.fontSize", 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_to_source_text() {
|
||||
let f = fx();
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
//! (pixels/second), evaluated through an arc-length table.
|
||||
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
|
||||
|
||||
pub mod text_doc;
|
||||
pub mod value;
|
||||
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
|
||||
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
|
||||
|
||||
/// Temporal interpolation on one side of a keyframe.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,76 +52,7 @@ impl ShapePath {
|
|||
}
|
||||
}
|
||||
|
||||
/// Horizontal paragraph alignment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
JustifyLastLeft,
|
||||
JustifyLastCenter,
|
||||
JustifyLastRight,
|
||||
JustifyAll,
|
||||
}
|
||||
|
||||
/// The value of a text layer's Source Text (hold-interpolated).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextDoc {
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub style: String,
|
||||
pub size: f64,
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
pub apply_fill: bool,
|
||||
pub apply_stroke: bool,
|
||||
pub stroke_over_fill: bool,
|
||||
pub tracking: f64,
|
||||
/// None = auto (120% of size).
|
||||
pub leading: Option<f64>,
|
||||
pub justify: Justify,
|
||||
pub baseline_shift: f64,
|
||||
pub h_scale: f64,
|
||||
pub v_scale: f64,
|
||||
pub faux_bold: bool,
|
||||
pub faux_italic: bool,
|
||||
pub all_caps: bool,
|
||||
pub small_caps: bool,
|
||||
/// Paragraph text box (width, height) with top-left at `box_pos`; None = point text.
|
||||
pub box_size: Option<[f64; 2]>,
|
||||
pub box_pos: [f64; 2],
|
||||
}
|
||||
|
||||
impl Default for TextDoc {
|
||||
fn default() -> Self {
|
||||
TextDoc {
|
||||
text: String::new(),
|
||||
font: "Inter".into(),
|
||||
style: "Regular".into(),
|
||||
size: 72.0,
|
||||
fill: [1.0, 1.0, 1.0, 1.0],
|
||||
stroke: [0.0, 0.0, 0.0, 1.0],
|
||||
stroke_width: 0.0,
|
||||
apply_fill: true,
|
||||
apply_stroke: false,
|
||||
stroke_over_fill: true,
|
||||
tracking: 0.0,
|
||||
leading: None,
|
||||
justify: Justify::Left,
|
||||
baseline_shift: 0.0,
|
||||
h_scale: 100.0,
|
||||
v_scale: 100.0,
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
all_caps: false,
|
||||
small_caps: false,
|
||||
box_size: None,
|
||||
box_pos: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use crate::text_doc::{Justify, TextDoc};
|
||||
|
||||
/// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -345,10 +276,13 @@ impl Value {
|
|||
Value::Text(t) => {
|
||||
if let Some(s) = j.as_str() {
|
||||
let mut t = (**t).clone();
|
||||
t.text = s.to_string();
|
||||
t.set_text(s);
|
||||
Some(Value::Text(Box::new(t)))
|
||||
} else {
|
||||
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
|
||||
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|mut t| {
|
||||
t.normalize();
|
||||
Value::Text(Box::new(t))
|
||||
})
|
||||
}
|
||||
}
|
||||
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
|
||||
|
|
|
|||
|
|
@ -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<CuePoint>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -140,11 +140,14 @@ pub struct RenderOpts {
|
|||
pub draft: bool,
|
||||
/// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera.
|
||||
pub view: Option<three_d::CameraState>,
|
||||
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
|
||||
/// just this rectangle, and only its pixels are composited.
|
||||
pub roi: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None }
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +349,26 @@ impl<'a> Renderer<'a> {
|
|||
let s = self.opts.scale;
|
||||
let w = ((comp.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((comp.height as f64 * s).round() as u32).max(1);
|
||||
// Region of interest on a comp with 3D layers: render the frame and crop it.
|
||||
if self.depth == 0
|
||||
&& let Some(r) = self.opts.roi
|
||||
&& comp.has_3d()
|
||||
{
|
||||
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
|
||||
let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32);
|
||||
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
|
||||
let mut out = Image::new(rw, rh);
|
||||
for y in 0..rh.min(full.height.saturating_sub(y0)) {
|
||||
for x in 0..rw.min(full.width.saturating_sub(x0)) {
|
||||
out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let (w, h) = match self.roi_offset() {
|
||||
Some((_, _, rw, rh)) => (rw, rh),
|
||||
None => (w, h),
|
||||
};
|
||||
let mut canvas = Image::new(w, h);
|
||||
if self.depth > 16 {
|
||||
return canvas;
|
||||
|
|
@ -739,12 +762,35 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
|
||||
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
|
||||
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
let r = self.opts.roi.filter(|_| self.depth == 0)?;
|
||||
let s = self.opts.scale;
|
||||
if r[2] <= 0.0 || r[3] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
|
||||
}
|
||||
|
||||
/// Scaled comp pixel → output pixel: the region of interest's offset.
|
||||
fn out_matrix(&self) -> Mat3 {
|
||||
match self.roi_offset() {
|
||||
Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)),
|
||||
None => Mat3::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer pixel → output pixel matrix for the layer at the context time.
|
||||
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let l2c = self.outer.map_or(l2c, |o| o * l2c);
|
||||
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
self.out_matrix()
|
||||
* Mat3::scale(vec2(s, s))
|
||||
* l2c
|
||||
* Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale))
|
||||
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
}
|
||||
|
||||
/// Motion-blur sub-samples for a layer (1 = no motion blur).
|
||||
|
|
@ -947,7 +993,8 @@ impl<'a> Renderer<'a> {
|
|||
let _ = masks::apply(ctx, layer, &mut foot);
|
||||
let mut matte = Image::new(canvas.width, canvas.height);
|
||||
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
|
||||
let mut below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
|
||||
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
|
||||
let mut below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale };
|
||||
// Effects run in the working space, not the linear blending space.
|
||||
if let Some(c) = self.pipe.from_blend() {
|
||||
color::convert(&mut below.img, &c);
|
||||
|
|
@ -1015,6 +1062,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_remap;
|
||||
#[cfg(test)]
|
||||
mod tests_roi;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
|
||||
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
|
||||
|
|
|
|||
|
|
@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
|
|||
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
|
||||
}
|
||||
|
||||
/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the
|
||||
/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents.
|
||||
pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option<Mat3> {
|
||||
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
|
||||
for sub in g.groups() {
|
||||
if sub.uid == uid {
|
||||
return Some(m);
|
||||
}
|
||||
if sub.match_id == "group"
|
||||
&& let Some(inner) = sub.sub("contents")
|
||||
{
|
||||
let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY);
|
||||
if let Some(r) = walk(ctx, layer, inner, uid, m * gm) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY)
|
||||
}
|
||||
|
||||
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
|
||||
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
//! Region of interest: a ROI render equals the matching crop of the full render.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{Image, NoFootage, RenderOpts, Renderer};
|
||||
|
||||
fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut layers = vec![];
|
||||
for (i, (c, w, h, pos, rot)) in
|
||||
[([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
|
||||
l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot);
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0);
|
||||
if i == 1 {
|
||||
l.blend_mode = BlendMode::Screen;
|
||||
l.switches.three_d = three_d;
|
||||
}
|
||||
layers.push(l);
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers = layers;
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
|
||||
let mut out = Image::new(w, h);
|
||||
for yy in 0..h {
|
||||
for xx in 0..w {
|
||||
out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_same(a: &Image, b: &Image) {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roi_render_equals_cropped_full_render() {
|
||||
for three_d in [false, true] {
|
||||
let (p, cid) = project(three_d);
|
||||
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!((roi.width, roi.height), (80, 60));
|
||||
assert_same(&roi, &crop(&full, 30, 20, 80, 60));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roi_at_half_resolution() {
|
||||
let (p, cid) = project(false);
|
||||
let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!((roi.width, roi.height), (30, 20));
|
||||
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use effectcraft_project::{Layer, PropGroup};
|
|||
use effectcraft_raster::Image;
|
||||
use effectcraft_text::path_text::{self, PathMeasure};
|
||||
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
|
||||
use effectcraft_text::{CharGlyph, TextLayout, char_glyph, layout_doc};
|
||||
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
|
|
@ -402,6 +402,8 @@ pub struct PlacedGlyph {
|
|||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
/// The character's style fills it (Character panel fill on).
|
||||
pub apply_fill: bool,
|
||||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
|
|
@ -479,14 +481,14 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
let mut subs: Vec<Option<char>> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect();
|
||||
let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3);
|
||||
let (lay, xfs) = if relayout {
|
||||
let src: Vec<char> = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect();
|
||||
let mut chars = src.clone();
|
||||
let mut chars: Vec<char> = doc.text.chars().collect();
|
||||
for (g, s) in lay.glyphs.iter().zip(&subs) {
|
||||
if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) {
|
||||
*slot = *c;
|
||||
}
|
||||
}
|
||||
let d2 = TextDoc { text: chars.into_iter().collect(), all_caps: false, ..doc.clone() };
|
||||
// Same character count: the style runs still line up.
|
||||
let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() };
|
||||
let l2 = layout_doc(&d2);
|
||||
let by_char: std::collections::HashMap<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
|
||||
let x2: Vec<CharXf> = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect();
|
||||
|
|
@ -506,11 +508,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
while li_end < n && lay.glyphs[li_end].line_index == line {
|
||||
li_end += 1;
|
||||
}
|
||||
let em = doc.size / 1000.0;
|
||||
let mut pen = 0.0;
|
||||
let (mut la, mut la_k) = (0.0, 0.0);
|
||||
for gi in li_start..li_end {
|
||||
let x = &xfs[gi];
|
||||
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
|
||||
pen += x.track_before * em;
|
||||
shift[gi] = pen;
|
||||
pen += x.track_after * em;
|
||||
|
|
@ -581,7 +583,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
}
|
||||
}
|
||||
|
||||
let base_stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
|
||||
let base = doc.base_style();
|
||||
let mut glyphs = Vec::with_capacity(n);
|
||||
for gi in 0..n {
|
||||
let g = &lay.glyphs[gi];
|
||||
|
|
@ -597,10 +599,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
x.rotation_y = 0.0;
|
||||
}
|
||||
let o = origin(gi);
|
||||
let st = lay.styles.get(g.run).unwrap_or(&base);
|
||||
let size = st.size;
|
||||
// Outline (substituted characters aligned in the original slot).
|
||||
let (outline, adv) = match subs[gi] {
|
||||
Some(c) => {
|
||||
let (p, a) = char_glyph(&doc, c);
|
||||
let (p, a) = char_glyph_style(st, c);
|
||||
let dx = match x.char_align {
|
||||
0 => 0.0,
|
||||
2 => g.advance - a,
|
||||
|
|
@ -614,8 +618,8 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
continue;
|
||||
}
|
||||
let pivot = match extents.get(&group_key(g)) {
|
||||
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * doc.size],
|
||||
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * doc.size],
|
||||
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * size],
|
||||
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * size],
|
||||
};
|
||||
// Outline relative to the pivot.
|
||||
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
|
||||
|
|
@ -635,9 +639,10 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
}
|
||||
None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m,
|
||||
};
|
||||
let fill = adjust_color(doc.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
|
||||
let stroke = adjust_color(doc.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
|
||||
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0) });
|
||||
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
|
||||
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
|
||||
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
|
||||
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill });
|
||||
}
|
||||
Some(TextGeom { doc, glyphs, fill_stroke, blend })
|
||||
}
|
||||
|
|
@ -654,21 +659,21 @@ type Pass = (usize, bool);
|
|||
fn passes(geom: &TextGeom) -> Vec<Pass> {
|
||||
let doc = &geom.doc;
|
||||
let n = geom.glyphs.len();
|
||||
let fill_on = doc.apply_fill;
|
||||
let fill_on = |i: usize| geom.glyphs[i].apply_fill;
|
||||
let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0;
|
||||
let mut v = Vec::with_capacity(n * 2);
|
||||
match geom.fill_stroke {
|
||||
1 => {
|
||||
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
|
||||
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
|
||||
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
|
||||
}
|
||||
2 => {
|
||||
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
|
||||
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
|
||||
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
|
||||
}
|
||||
_ => {
|
||||
for i in 0..n {
|
||||
let f = fill_on.then_some((i, false));
|
||||
let f = fill_on(i).then_some((i, false));
|
||||
let s = has_stroke(i).then_some((i, true));
|
||||
if doc.stroke_over_fill {
|
||||
v.extend(f);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
|
||||
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,
|
||||
//! tracking, baseline shift, all caps / small caps, underline. Results are cached.
|
||||
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14, greedy or every-line),
|
||||
//! alignment and justification, indents, paragraph spacing, leading, tracking, tsume, manual and
|
||||
//! optical kerning, baseline shift, superscript / subscript, horizontal / vertical scale, all caps
|
||||
//! / small caps, hanging punctuation, underline. Text may mix character styles (runs) and
|
||||
//! paragraph settings. Results are cached.
|
||||
//!
|
||||
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
|
||||
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
|
||||
|
|
@ -34,6 +37,19 @@ pub enum Align {
|
|||
Justify,
|
||||
}
|
||||
|
||||
/// Superscript / subscript position.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Script {
|
||||
#[default]
|
||||
Normal,
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Superscript / subscript size and baseline offset (fractions of the font size).
|
||||
pub const SCRIPT_SIZE: f32 = 0.583;
|
||||
pub const SCRIPT_OFFSET: f32 = 0.333;
|
||||
|
||||
/// Character formatting.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
|
|
@ -45,6 +61,10 @@ pub struct TextStyle {
|
|||
pub tracking: f32,
|
||||
/// Metric kerning (`kern`).
|
||||
pub kerning: bool,
|
||||
/// Optical kerning (spacing from the outlines; replaces metric kerning).
|
||||
pub optical: bool,
|
||||
/// Manual kerning before every character of the run, in 1/1000 em (replaces metrics).
|
||||
pub manual_kern: Option<f32>,
|
||||
/// Standard ligatures (`liga`, `clig`).
|
||||
pub ligatures: bool,
|
||||
/// Baseline shift in pixels (positive = up).
|
||||
|
|
@ -53,6 +73,14 @@ pub struct TextStyle {
|
|||
pub faux_italic: bool,
|
||||
pub caps: Caps,
|
||||
pub underline: bool,
|
||||
/// Horizontal / vertical scale (1 = 100%).
|
||||
pub h_scale: f32,
|
||||
pub v_scale: f32,
|
||||
/// Tsume: fraction (0–1) of the space around each glyph removed.
|
||||
pub tsume: f32,
|
||||
pub script: Script,
|
||||
/// Baseline-to-baseline distance in pixels; None = auto (120% of the size).
|
||||
pub leading: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for TextStyle {
|
||||
|
|
@ -63,26 +91,87 @@ impl Default for TextStyle {
|
|||
size: 100.0,
|
||||
tracking: 0.0,
|
||||
kerning: true,
|
||||
optical: false,
|
||||
manual_kern: None,
|
||||
ligatures: true,
|
||||
baseline_shift: 0.0,
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
caps: Caps::Normal,
|
||||
underline: false,
|
||||
h_scale: 1.0,
|
||||
v_scale: 1.0,
|
||||
tsume: 0.0,
|
||||
script: Script::Normal,
|
||||
leading: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// The size glyphs are drawn at (superscript / subscript are smaller).
|
||||
pub fn glyph_size(&self) -> f32 {
|
||||
match self.script {
|
||||
Script::Normal => self.size,
|
||||
_ => self.size * SCRIPT_SIZE,
|
||||
}
|
||||
}
|
||||
/// Upward baseline offset: baseline shift plus the superscript / subscript offset.
|
||||
pub fn rise(&self) -> f32 {
|
||||
self.baseline_shift
|
||||
+ match self.script {
|
||||
Script::Normal => 0.0,
|
||||
Script::Super => self.size * SCRIPT_OFFSET,
|
||||
Script::Sub => -self.size * SCRIPT_OFFSET,
|
||||
}
|
||||
}
|
||||
/// The line advance this style asks for.
|
||||
pub fn line_advance(&self) -> f32 {
|
||||
self.leading.unwrap_or(self.size * 1.2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph formatting.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParagraphStyle {
|
||||
pub align: Align,
|
||||
/// Extra line spacing in pixels added to the font's natural line height (Leading).
|
||||
/// How the last line of a justified paragraph is aligned (`Justify` = justify it too).
|
||||
pub justify_last: Align,
|
||||
/// Extra line spacing in pixels added to every line advance.
|
||||
pub leading: f32,
|
||||
/// Wrap width (area text); None = point text.
|
||||
/// Wrap width (area text); None = point text. Taken from the first paragraph.
|
||||
pub width: Option<f32>,
|
||||
/// Base direction: None = from the first strong character.
|
||||
pub rtl: Option<bool>,
|
||||
/// Start / end margins (left / right in left-to-right paragraphs) and the first line's indent.
|
||||
pub indent_start: f32,
|
||||
pub indent_end: f32,
|
||||
pub indent_first: f32,
|
||||
pub space_before: f32,
|
||||
pub space_after: f32,
|
||||
/// Every-line composer (balanced breaks) instead of single-line (greedy).
|
||||
pub every_line: bool,
|
||||
/// Roman hanging punctuation.
|
||||
pub hanging: bool,
|
||||
}
|
||||
|
||||
impl Default for ParagraphStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
align: Align::Left,
|
||||
justify_last: Align::Left,
|
||||
leading: 0.0,
|
||||
width: None,
|
||||
rtl: None,
|
||||
indent_start: 0.0,
|
||||
indent_end: 0.0,
|
||||
indent_first: 0.0,
|
||||
space_before: 0.0,
|
||||
space_after: 0.0,
|
||||
every_line: false,
|
||||
hanging: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hf(h: &mut impl Hasher, v: f32) {
|
||||
|
|
@ -93,19 +182,23 @@ impl Hash for TextStyle {
|
|||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.family.hash(h);
|
||||
self.style.hash(h);
|
||||
hf(h, self.size);
|
||||
hf(h, self.tracking);
|
||||
hf(h, self.baseline_shift);
|
||||
(self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);
|
||||
for v in [self.size, self.tracking, self.baseline_shift, self.h_scale, self.v_scale, self.tsume] {
|
||||
hf(h, v);
|
||||
}
|
||||
self.manual_kern.map(f32::to_bits).hash(h);
|
||||
self.leading.map(f32::to_bits).hash(h);
|
||||
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ParagraphStyle {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.align.hash(h);
|
||||
hf(h, self.leading);
|
||||
(self.align, self.justify_last).hash(h);
|
||||
for v in [self.leading, self.indent_start, self.indent_end, self.indent_first, self.space_before, self.space_after] {
|
||||
hf(h, v);
|
||||
}
|
||||
self.width.map(f32::to_bits).hash(h);
|
||||
self.rtl.hash(h);
|
||||
(self.rtl, self.every_line, self.hanging).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +214,11 @@ pub struct Glyph {
|
|||
pub cluster: usize,
|
||||
pub synth_bold: bool,
|
||||
pub synth_italic: bool,
|
||||
/// Horizontal / vertical scale of the outline (1 = 100%).
|
||||
pub h_scale: f32,
|
||||
pub v_scale: f32,
|
||||
/// Index of the style run the glyph belongs to.
|
||||
pub run: usize,
|
||||
}
|
||||
|
||||
/// One laid-out line.
|
||||
|
|
@ -137,6 +235,10 @@ pub struct Line {
|
|||
pub glyphs: Range<usize>,
|
||||
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
|
||||
pub carets: Vec<(usize, f32)>,
|
||||
/// Paragraph index.
|
||||
pub para: usize,
|
||||
/// The line ends its paragraph.
|
||||
pub para_end: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
|
@ -175,7 +277,20 @@ impl Layout {
|
|||
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
|
||||
return 0;
|
||||
};
|
||||
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
||||
self.hit_line(l, x)
|
||||
}
|
||||
/// Nearest caret byte offset to `x` on line `l`. A soft-wrapped line's end is the caret
|
||||
/// before its trailing space, so clicking past it doesn't land on the next line.
|
||||
pub fn hit_line(&self, l: &Line, x: f32) -> usize {
|
||||
let end = self.line_end(l);
|
||||
l.carets.iter().filter(|c| c.0 <= end).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
||||
}
|
||||
/// The last caret stop of a line (before a wrapped line's trailing space).
|
||||
pub fn line_end(&self, l: &Line) -> usize {
|
||||
if l.para_end {
|
||||
return l.range.end;
|
||||
}
|
||||
l.carets.iter().rev().nth(1).map(|c| c.0).filter(|b| *b >= l.range.start).unwrap_or(l.range.end)
|
||||
}
|
||||
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
|
||||
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
|
||||
|
|
@ -193,7 +308,10 @@ impl Layout {
|
|||
}
|
||||
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
|
||||
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
|
||||
if b > l.range.end {
|
||||
if x0 > x1 {
|
||||
x1 = l.x;
|
||||
}
|
||||
if b > l.range.end && l.para_end {
|
||||
x1 += l.ascent * 0.25; // selected newline
|
||||
}
|
||||
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
|
||||
|
|
@ -218,9 +336,12 @@ struct ShapedGlyph {
|
|||
id: u32,
|
||||
cluster: usize,
|
||||
adv: f32,
|
||||
/// Space added before the glyph (manual / optical kerning).
|
||||
pre: f32,
|
||||
dx: f32,
|
||||
dy: f32,
|
||||
size: f32,
|
||||
run: usize,
|
||||
}
|
||||
|
||||
struct Item {
|
||||
|
|
@ -239,7 +360,17 @@ fn upper_single(c: char) -> char {
|
|||
|
||||
const SMALL_CAPS_SCALE: f32 = 0.78;
|
||||
|
||||
fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec<ShapedGlyph> {
|
||||
/// Horizontal ink extent `(x0, x1)` of a glyph in font units.
|
||||
fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
|
||||
let p = crate::outline_units(face, gid)?;
|
||||
if p.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let r = kurbo::Shape::bounding_box(&*p);
|
||||
Some((r.x0 as f32, r.x1 as f32))
|
||||
}
|
||||
|
||||
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec<ShapedGlyph> {
|
||||
let f = fonts::face(face);
|
||||
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
|
||||
let shaper = data.shaper(&font).build();
|
||||
|
|
@ -250,7 +381,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
|
|||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let mut feats = Vec::new();
|
||||
if !style.kerning {
|
||||
if !style.kerning || style.optical || style.manual_kern.is_some() {
|
||||
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
|
||||
}
|
||||
if !style.ligatures {
|
||||
|
|
@ -259,7 +390,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
|
|||
}
|
||||
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
|
||||
let k = size / f.units_per_em();
|
||||
let _ = text;
|
||||
let hs = style.h_scale;
|
||||
out.glyph_infos()
|
||||
.iter()
|
||||
.zip(out.glyph_positions())
|
||||
|
|
@ -267,77 +398,180 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
|
|||
face,
|
||||
id: i.glyph_id,
|
||||
cluster: i.cluster as usize,
|
||||
adv: p.x_advance as f32 * k,
|
||||
dx: p.x_offset as f32 * k,
|
||||
adv: p.x_advance as f32 * k * hs,
|
||||
pre: 0.0,
|
||||
dx: p.x_offset as f32 * k * hs,
|
||||
dy: p.y_offset as f32 * k,
|
||||
size,
|
||||
run,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tsume, tracking, manual and optical kerning on one shaped item (logical order).
|
||||
fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_start: bool) {
|
||||
let side = |g: &ShapedGlyph| -> Option<(f32, f32)> {
|
||||
let (x0, x1) = ink_x(g.face, g.id)?;
|
||||
let k = g.size / fonts::face(g.face).units_per_em() * style.h_scale;
|
||||
Some((x0 * k, g.adv - x1 * k))
|
||||
};
|
||||
let is_space = |g: &ShapedGlyph| text[g.cluster..].chars().next().is_none_or(char::is_whitespace);
|
||||
if style.tsume > 0.0 {
|
||||
let t = style.tsume.clamp(0.0, 1.0);
|
||||
for g in glyphs.iter_mut() {
|
||||
if is_space(g) {
|
||||
continue;
|
||||
}
|
||||
if let Some((lsb, rsb)) = side(g) {
|
||||
g.dx -= t * lsb.max(0.0);
|
||||
g.adv -= t * (lsb.max(0.0) + rsb.max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if style.optical && glyphs.len() > 1 {
|
||||
// Pull every pair's ink gap halfway toward the item's median gap.
|
||||
let sides: Vec<Option<(f32, f32)>> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect();
|
||||
let gaps: Vec<Option<f32>> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect();
|
||||
let mut sorted: Vec<f32> = gaps.iter().flatten().copied().collect();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
if let Some(&median) = sorted.get(sorted.len() / 2) {
|
||||
for (i, gap) in gaps.iter().enumerate() {
|
||||
if let Some(gap) = gap
|
||||
&& glyphs[i + 1].cluster != glyphs[i].cluster
|
||||
{
|
||||
glyphs[i + 1].pre += (median - gap) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = style.manual_kern {
|
||||
let k = m * style.size / 1000.0;
|
||||
for i in 0..glyphs.len() {
|
||||
let first_of_cluster = i == 0 || glyphs[i - 1].cluster != glyphs[i].cluster;
|
||||
if first_of_cluster && !(para_start && glyphs[i].cluster == 0) {
|
||||
glyphs[i].pre += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
if style.tracking != 0.0 {
|
||||
let t = style.tracking * style.size / 1000.0;
|
||||
for k in 0..glyphs.len() {
|
||||
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
|
||||
glyphs[k].adv += t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ParaLine {
|
||||
range: Range<usize>,
|
||||
glyphs: Vec<Glyph>,
|
||||
carets: Vec<(usize, f32)>,
|
||||
content: f32,
|
||||
left_trim: f32,
|
||||
/// Hanging punctuation widths at the left / right edge.
|
||||
hang: (f32, f32),
|
||||
ascent: f32,
|
||||
descent: f32,
|
||||
/// Line advance (largest leading on the line).
|
||||
advance: f32,
|
||||
align: Align,
|
||||
avail: Option<f32>,
|
||||
margins: (f32, f32),
|
||||
underline: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
fn is_hanging_close(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '-' | '\u{2013}' | '\u{2014}' | '\'' | '"' | '\u{2019}' | '\u{201d}' | ')' | '\u{2026}')
|
||||
}
|
||||
fn is_hanging_open(c: char) -> bool {
|
||||
matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}')
|
||||
}
|
||||
|
||||
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
|
||||
/// paragraphs at the end).
|
||||
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
|
||||
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
|
||||
fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec<ParaLine> {
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
|
||||
let rtl_para = para.rtl == Some(true);
|
||||
let margins_for = |first: bool| -> (f32, f32) {
|
||||
let fi = if first { para.indent_first } else { 0.0 };
|
||||
if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
|
||||
};
|
||||
let avail_for = |first: bool| -> Option<f32> {
|
||||
let (l, r) = margins_for(first);
|
||||
width.map(|w| (w - l - r).max(1.0))
|
||||
};
|
||||
let style_metrics = |si: usize| {
|
||||
let st = &runs[si].1;
|
||||
fonts::face(primaries[si].face).metrics(st.glyph_size())
|
||||
};
|
||||
let eff_align = |last: bool| -> Align {
|
||||
if (last || width.is_none()) && para.align == Align::Justify {
|
||||
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
|
||||
} else {
|
||||
para.align
|
||||
}
|
||||
};
|
||||
if text.is_empty() {
|
||||
let si = run_at(runs, base);
|
||||
let pm = style_metrics(si);
|
||||
return vec![ParaLine {
|
||||
range: base..base,
|
||||
glyphs: vec![],
|
||||
carets: vec![(base, 0.0)],
|
||||
content: 0.0,
|
||||
left_trim: 0.0,
|
||||
hang: (0.0, 0.0),
|
||||
ascent: pm.ascent,
|
||||
descent: pm.descent,
|
||||
advance: runs[si].1.line_advance(),
|
||||
align: eff_align(true),
|
||||
avail: avail_for(true),
|
||||
margins: margins_for(true),
|
||||
underline: None,
|
||||
}];
|
||||
}
|
||||
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
|
||||
let bidi = BidiInfo::new(text, default_level);
|
||||
let pinfo = &bidi.paragraphs[0];
|
||||
let base_rtl = pinfo.level.is_rtl();
|
||||
// per char: (byte, char, shaped char), face, level, small
|
||||
let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text
|
||||
// per char: (byte, char, shaped char, face, rtl, small caps, run)
|
||||
let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text
|
||||
.char_indices()
|
||||
.map(|(b, c)| {
|
||||
let si = run_at(runs, base + b);
|
||||
let style = &runs[si].1;
|
||||
let (sc, small) = match style.caps {
|
||||
Caps::Normal => (c, false),
|
||||
Caps::All => (upper_single(c), false),
|
||||
Caps::Small if c.is_lowercase() => (upper_single(c), true),
|
||||
Caps::Small => (c, false),
|
||||
};
|
||||
let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) };
|
||||
(b, c, sc, face, bidi.levels[b].is_rtl(), small)
|
||||
let primary = primaries[si].face;
|
||||
let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) };
|
||||
(b, c, sc, face, bidi.levels[b].is_rtl(), small, si)
|
||||
})
|
||||
.collect();
|
||||
// items: runs of equal (face, rtl, small)
|
||||
// items: runs of equal (face, rtl, small, style)
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5);
|
||||
let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6);
|
||||
let k0 = key(&chars[i]);
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small {
|
||||
while j < chars.len() && key(&chars[j]) == k0 {
|
||||
j += 1;
|
||||
}
|
||||
let (face, rtl, small, si) = k0;
|
||||
let style = &runs[si].1;
|
||||
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
|
||||
let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size };
|
||||
let mut glyphs = shape_item(text, &sub, rtl, face, size, style);
|
||||
// tracking after each cluster
|
||||
if style.tracking != 0.0 {
|
||||
let t = style.tracking * style.size / 1000.0;
|
||||
for k in 0..glyphs.len() {
|
||||
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
|
||||
glyphs[k].adv += t;
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 };
|
||||
let mut glyphs = shape_item(&sub, rtl, face, size, style, si);
|
||||
space_item(&mut glyphs, style, text, true);
|
||||
let end = if j < chars.len() { chars[j].0 } else { text.len() };
|
||||
items.push(Item { range: chars[i].0..end, rtl, glyphs });
|
||||
i = j;
|
||||
|
|
@ -346,7 +580,7 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
let mut char_adv = vec![0.0f32; text.len() + 1];
|
||||
for it in &items {
|
||||
for g in &it.glyphs {
|
||||
char_adv[g.cluster.min(text.len())] += g.adv;
|
||||
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
|
||||
}
|
||||
}
|
||||
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
|
||||
|
|
@ -362,28 +596,83 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
}
|
||||
(end, w)
|
||||
};
|
||||
// Hanging punctuation widths (left, right) of a line's content.
|
||||
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
|
||||
if !para.hanging || width.is_none() || r.start >= ce {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let first = text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| char_adv[r.start]);
|
||||
let last = text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| char_adv[r.start + b]);
|
||||
(first, last)
|
||||
};
|
||||
let fits_w = |r: &Range<usize>| -> f32 {
|
||||
let (ce, _) = trailing_ws(r);
|
||||
let (hl, hr) = hang_of(r, ce);
|
||||
range_w(r.start..ce) - hl - hr
|
||||
};
|
||||
// line breaking
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
match para.width {
|
||||
match width {
|
||||
None => ranges.push(0..text.len()),
|
||||
Some(maxw) => {
|
||||
let mut start = 0usize;
|
||||
let mut last_ok: Option<usize> = None;
|
||||
for (p, _) in unicode_linebreak::linebreaks(text) {
|
||||
loop {
|
||||
let r = start..p;
|
||||
let (ce, _) = trailing_ws(&r);
|
||||
let w = range_w(start..ce);
|
||||
if w <= maxw || last_ok.is_none() {
|
||||
last_ok = Some(p);
|
||||
break;
|
||||
Some(_) => {
|
||||
let opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect();
|
||||
let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX);
|
||||
if para.every_line && opps.len() > 1 && opps.len() <= 1500 {
|
||||
// Minimise the summed squared slack of every line but the last.
|
||||
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
|
||||
let m = nodes.len();
|
||||
let mut best = vec![(f32::INFINITY, 0usize); m];
|
||||
best[0] = (0.0, 0);
|
||||
for j in 1..m {
|
||||
for i in (0..j).rev() {
|
||||
if !best[i].0.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let r = nodes[i]..nodes[j];
|
||||
let w = fits_w(&r);
|
||||
let maxw = avail(usize::from(i != 0));
|
||||
let over = w > maxw;
|
||||
if over && i + 1 != j {
|
||||
break;
|
||||
}
|
||||
let slack = maxw - w;
|
||||
let cost = if over {
|
||||
1e12 + w
|
||||
} else if j == m - 1 {
|
||||
0.0
|
||||
} else {
|
||||
slack * slack
|
||||
};
|
||||
let total = best[i].0 + cost;
|
||||
if total < best[j].0 {
|
||||
best[j] = (total, i);
|
||||
}
|
||||
}
|
||||
let b = last_ok.take().expect("checked");
|
||||
ranges.push(start..b);
|
||||
start = b;
|
||||
}
|
||||
let mut j = m - 1;
|
||||
while j > 0 {
|
||||
let i = best[j].1;
|
||||
ranges.push(nodes[i]..nodes[j]);
|
||||
j = i;
|
||||
}
|
||||
ranges.reverse();
|
||||
} else {
|
||||
let mut start = 0usize;
|
||||
let mut last_ok: Option<usize> = None;
|
||||
for p in opps {
|
||||
loop {
|
||||
let w = fits_w(&(start..p));
|
||||
if w <= avail(ranges.len()) || last_ok.is_none() {
|
||||
last_ok = Some(p);
|
||||
break;
|
||||
}
|
||||
let b = last_ok.take().expect("checked");
|
||||
ranges.push(start..b);
|
||||
start = b;
|
||||
}
|
||||
}
|
||||
ranges.push(start..text.len());
|
||||
}
|
||||
ranges.push(start..text.len());
|
||||
ranges.retain(|r| !r.is_empty());
|
||||
if ranges.is_empty() {
|
||||
ranges.push(0..text.len());
|
||||
|
|
@ -393,24 +682,28 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
let nlines = ranges.len();
|
||||
let mut out = Vec::new();
|
||||
for (li, r) in ranges.into_iter().enumerate() {
|
||||
let last = li + 1 == nlines;
|
||||
let align = eff_align(last);
|
||||
let avail = avail_for(li == 0);
|
||||
let (ce, tw) = trailing_ws(&r);
|
||||
let hang = hang_of(&r, ce);
|
||||
let content = range_w(r.start..ce);
|
||||
// justification
|
||||
let mut space_extra = 0.0;
|
||||
if para.align == Align::Justify
|
||||
&& li + 1 < nlines
|
||||
&& let Some(maxw) = para.width
|
||||
if align == Align::Justify
|
||||
&& let Some(maxw) = avail
|
||||
{
|
||||
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
|
||||
if spaces > 0 {
|
||||
space_extra = ((maxw - content) / spaces as f32).max(0.0);
|
||||
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
|
||||
}
|
||||
}
|
||||
let (levels, runs) = bidi.visual_runs(pinfo, r.clone());
|
||||
let (levels, vruns) = bidi.visual_runs(pinfo, r.clone());
|
||||
let mut pen = 0.0f32;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
|
||||
for run in runs {
|
||||
let mut underline: Option<(f32, f32)> = None;
|
||||
for run in vruns {
|
||||
let rtl = levels[run.start].is_rtl();
|
||||
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
|
||||
if rtl {
|
||||
|
|
@ -418,21 +711,31 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
}
|
||||
for k in idx {
|
||||
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
|
||||
let st = &runs[g.run].1;
|
||||
let x0 = pen;
|
||||
pen += g.pre;
|
||||
glyphs.push(Glyph {
|
||||
face: g.face,
|
||||
id: g.id,
|
||||
x: pen + g.dx,
|
||||
y: -g.dy - style.baseline_shift,
|
||||
y: -g.dy - st.rise(),
|
||||
size: g.size,
|
||||
cluster: base + g.cluster,
|
||||
synth_bold: primary.synth_bold || style.faux_bold,
|
||||
synth_italic: primary.synth_italic || style.faux_italic,
|
||||
synth_bold: primaries[g.run].synth_bold || st.faux_bold,
|
||||
synth_italic: primaries[g.run].synth_italic || st.faux_italic,
|
||||
h_scale: st.h_scale,
|
||||
v_scale: st.v_scale,
|
||||
run: g.run,
|
||||
});
|
||||
pen += g.adv;
|
||||
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
|
||||
pen += space_extra;
|
||||
}
|
||||
if st.underline && g.cluster < ce {
|
||||
let u = underline.get_or_insert((x0, pen));
|
||||
u.0 = u.0.min(x0);
|
||||
u.1 = u.1.max(pen);
|
||||
}
|
||||
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
|
||||
e.0 = e.0.min(x0);
|
||||
e.1 = e.1.max(pen);
|
||||
|
|
@ -475,53 +778,95 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
carets.push((base + r.end, end_x));
|
||||
carets.sort_by_key(|c| c.0);
|
||||
carets.dedup_by_key(|c| c.0);
|
||||
let mut asc = pm.ascent;
|
||||
let mut desc = pm.descent;
|
||||
// Line metrics: the tallest face and the largest leading on the line.
|
||||
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
|
||||
let mut seen: Vec<usize> = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
if seen.is_empty() {
|
||||
seen.push(chars.first().map_or(0, |c| c.6));
|
||||
}
|
||||
for &si in &seen {
|
||||
let m = style_metrics(si);
|
||||
let rise = runs[si].1.rise();
|
||||
asc = asc.max(m.ascent + rise.max(0.0));
|
||||
desc = desc.max(m.descent - rise.min(0.0));
|
||||
adv = adv.max(runs[si].1.line_advance());
|
||||
}
|
||||
for g in &glyphs {
|
||||
if g.face != primary.face {
|
||||
if g.face != primaries[g.run].face {
|
||||
let m = fonts::face(g.face).metrics(g.size);
|
||||
asc = asc.max(m.ascent);
|
||||
desc = desc.max(m.descent);
|
||||
}
|
||||
}
|
||||
let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content };
|
||||
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
|
||||
out.push(ParaLine {
|
||||
range: base + r.start..base + r.end,
|
||||
glyphs,
|
||||
carets,
|
||||
content: content_w,
|
||||
left_trim: if base_rtl { tw } else { 0.0 },
|
||||
hang,
|
||||
ascent: asc,
|
||||
descent: desc,
|
||||
advance: adv,
|
||||
align,
|
||||
avail,
|
||||
margins: margins_for(li == 0),
|
||||
underline,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lay out `text` (paragraphs separated by `\n`).
|
||||
/// Lay out `text` (paragraphs separated by `\n`) in one style.
|
||||
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
|
||||
let primary = fonts::resolve(&style.family, &style.style);
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
|
||||
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
|
||||
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
|
||||
}
|
||||
|
||||
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
|
||||
/// one) and paragraph settings (one per `\n`-separated paragraph; the last repeats). The wrap
|
||||
/// width is the first paragraph's.
|
||||
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
|
||||
let fallback = [(0..text.len(), TextStyle::default())];
|
||||
let runs = if runs.is_empty() { &fallback[..] } else { runs };
|
||||
let default_para = ParagraphStyle::default();
|
||||
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
|
||||
let width = para_at(0).width;
|
||||
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
|
||||
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
|
||||
let mut base = 0usize;
|
||||
let mut plines = Vec::new();
|
||||
for p in text.split('\n') {
|
||||
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
|
||||
for (pi, p) in text.split('\n').enumerate() {
|
||||
let p_clean = p.strip_suffix('\r').unwrap_or(p);
|
||||
plines.extend(paragraph(p_clean, base, style, para, primary));
|
||||
plines.extend(paragraph(p_clean, base, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l)));
|
||||
base += p.len() + 1;
|
||||
}
|
||||
let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 };
|
||||
for (i, pl) in plines.into_iter().enumerate() {
|
||||
let baseline = first_baseline + line_h * i as f32;
|
||||
let shift = match (para.width, para.align) {
|
||||
(None, Align::Left | Align::Justify) => 0.0,
|
||||
(None, Align::Center) => -pl.content / 2.0,
|
||||
(None, Align::Right) => -pl.content,
|
||||
(Some(_), Align::Left | Align::Justify) => 0.0,
|
||||
(Some(w), Align::Center) => (w - pl.content) / 2.0,
|
||||
(Some(w), Align::Right) => w - pl.content,
|
||||
} - pl.left_trim;
|
||||
let mut baseline = 0.0f32;
|
||||
for (i, (pi, pl)) in plines.into_iter().enumerate() {
|
||||
let para = para_at(pi);
|
||||
if i == 0 {
|
||||
baseline = if width.is_some() { pl.ascent } else { 0.0 };
|
||||
} else {
|
||||
let prev_para = lay.lines.last().map_or(pi, |l| l.para);
|
||||
let mut adv = pl.advance + para.leading;
|
||||
if prev_para != pi {
|
||||
adv += para_at(prev_para).space_after + para.space_before;
|
||||
}
|
||||
baseline += adv;
|
||||
}
|
||||
let shown = pl.content - pl.hang.0 - pl.hang.1;
|
||||
let (ml, mr) = pl.margins;
|
||||
let shift = match (pl.avail, pl.align) {
|
||||
(None, Align::Left | Align::Justify) => ml,
|
||||
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
|
||||
(None, Align::Right) => -shown - mr,
|
||||
(Some(_), Align::Left | Align::Justify) => ml,
|
||||
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
|
||||
(Some(a), Align::Right) => ml + a - shown,
|
||||
} - pl.left_trim
|
||||
- pl.hang.0;
|
||||
let g0 = lay.glyphs.len();
|
||||
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
|
||||
g.x += shift;
|
||||
|
|
@ -529,8 +874,10 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
|
|||
g
|
||||
}));
|
||||
let x = shift + pl.left_trim;
|
||||
if style.underline && pl.content > 0.0 {
|
||||
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
|
||||
if let Some((u0, u1)) = pl.underline {
|
||||
let si = run_at(runs, pl.range.start);
|
||||
let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size);
|
||||
lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]);
|
||||
}
|
||||
lay.lines.push(Line {
|
||||
range: pl.range,
|
||||
|
|
@ -541,15 +888,20 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
|
|||
descent: pl.descent,
|
||||
glyphs: g0..lay.glyphs.len(),
|
||||
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
|
||||
para: pi,
|
||||
para_end: false,
|
||||
});
|
||||
}
|
||||
for i in 0..lay.lines.len() {
|
||||
lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para);
|
||||
}
|
||||
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
|
||||
for l in &lay.lines {
|
||||
x0 = x0.min(l.x);
|
||||
x1 = x1.max(l.x + l.width);
|
||||
}
|
||||
if let Some(w) = para.width {
|
||||
x0 = 0.0;
|
||||
if let Some(w) = width {
|
||||
x0 = x0.min(0.0);
|
||||
x1 = x1.max(w);
|
||||
}
|
||||
let first = &lay.lines[0];
|
||||
|
|
@ -563,12 +915,17 @@ fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
|
|||
C.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Lay out `text` (cached by text and styles).
|
||||
/// Lay out `text` in one style (cached by text and styles).
|
||||
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
|
||||
layout_rich(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
|
||||
}
|
||||
|
||||
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
|
||||
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
style.hash(&mut h);
|
||||
para.hash(&mut h);
|
||||
runs.hash(&mut h);
|
||||
paras.hash(&mut h);
|
||||
let key = h.finish();
|
||||
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
|
@ -576,7 +933,7 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layou
|
|||
e.0 = now;
|
||||
return e.1.clone();
|
||||
}
|
||||
let l = Arc::new(layout_uncached(text, style, para));
|
||||
let l = Arc::new(layout_rich_uncached(text, runs, paras));
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() >= 512 {
|
||||
// evict the oldest quarter
|
||||
|
|
@ -720,4 +1077,135 @@ mod tests {
|
|||
assert_eq!(l.caret(0).0, 0.0);
|
||||
assert!(l.bounds[3] > l.bounds[1]);
|
||||
}
|
||||
|
||||
fn rich(text: &str, runs: &[(Range<usize>, TextStyle)], para: ParagraphStyle) -> Layout {
|
||||
layout_rich_uncached(text, runs, &[para])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() {
|
||||
let text = "small BIG\nnext";
|
||||
let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))];
|
||||
let l = rich(text, &runs, ParagraphStyle::default());
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
let y0 = l.glyphs[0].y;
|
||||
assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line");
|
||||
assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0);
|
||||
// Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run.
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline);
|
||||
assert!(l.lines[0].ascent > 60.0);
|
||||
// A big run on the second line pushes it down by its 120% leading.
|
||||
let text2 = "aa\nBB";
|
||||
let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default());
|
||||
assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3);
|
||||
// Explicit leading wins over auto.
|
||||
let mut fixed = st(20.0);
|
||||
fixed.leading = Some(50.0);
|
||||
let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default());
|
||||
assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_subscript_tsume_and_kerning_metrics() {
|
||||
let base = st(100.0);
|
||||
let sup = TextStyle { script: Script::Super, ..base.clone() };
|
||||
let sub = TextStyle { script: Script::Sub, ..base.clone() };
|
||||
let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
|
||||
let g = &l.glyphs;
|
||||
assert!((g[1].size - 58.3).abs() < 0.1);
|
||||
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");
|
||||
assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered");
|
||||
// Tsume removes side bearings; full tsume is narrower than none.
|
||||
let w0 = measure("onion", &base);
|
||||
let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() });
|
||||
assert!(w1 < w0 - 5.0, "{w1} vs {w0}");
|
||||
// Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off.
|
||||
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() });
|
||||
let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() });
|
||||
assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}");
|
||||
let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() });
|
||||
assert!(optical > 0.0 && (optical - nokern).abs() < 100.0);
|
||||
// Horizontal scale stretches advances.
|
||||
let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() });
|
||||
assert!((wide - 2.0 * w0).abs() < 0.5);
|
||||
let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default());
|
||||
assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_and_paragraph_spacing() {
|
||||
let s = st(20.0);
|
||||
let text = "first paragraph here\nsecond one";
|
||||
let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() };
|
||||
let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() };
|
||||
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]);
|
||||
assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x);
|
||||
assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width);
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3);
|
||||
assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1));
|
||||
assert!(l.lines[0].para_end && l.lines[1].para_end);
|
||||
// Wrapped lines after the first don't get the first-line indent.
|
||||
let long = "word ".repeat(30);
|
||||
let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]);
|
||||
assert!(l.lines.len() > 2);
|
||||
assert!((l.lines[1].x - 30.0).abs() < 1e-3);
|
||||
assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5));
|
||||
assert!(!l.lines[0].para_end);
|
||||
// Point text: a right-aligned paragraph's end indent moves it left of the origin.
|
||||
let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]);
|
||||
assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justify_last_line_modes_and_every_line_composer() {
|
||||
let text = "the quick brown fox jumps over the lazy dog and keeps running far";
|
||||
let s = st(30.0);
|
||||
let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() };
|
||||
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
|
||||
let n = left.lines.len();
|
||||
assert!(n >= 3);
|
||||
assert!((left.lines[0].width - 320.0).abs() < 0.5);
|
||||
assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3);
|
||||
let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]);
|
||||
let lc = ¢er.lines[n - 1];
|
||||
assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5);
|
||||
let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]);
|
||||
let lr = &right.lines[n - 1];
|
||||
assert!((lr.x + lr.width - 320.0).abs() < 0.5);
|
||||
let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]);
|
||||
assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line");
|
||||
// Every-line composer: never worse (summed squared slack) than greedy breaking.
|
||||
let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() };
|
||||
let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]);
|
||||
let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]);
|
||||
assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy));
|
||||
assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hanging_punctuation_overhangs_the_box() {
|
||||
let s = st(40.0);
|
||||
let text = "\"Hi.\"";
|
||||
let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() };
|
||||
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
|
||||
assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x);
|
||||
let r = layout_rich_uncached(
|
||||
text,
|
||||
&[(0..text.len(), s)],
|
||||
&[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }],
|
||||
);
|
||||
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_carets_run_right_to_left() {
|
||||
let text = "\u{5d0}\u{5d1}\u{5d2}";
|
||||
let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]);
|
||||
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
|
||||
assert_eq!(xs.len(), 4);
|
||||
assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}");
|
||||
// Hit testing returns the logical position nearest the point.
|
||||
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
|
||||
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ pub mod path_text;
|
|||
pub mod selectors;
|
||||
pub mod sfnt;
|
||||
|
||||
pub use kurbo;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::{Justify, TextDoc};
|
||||
use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, TextDoc};
|
||||
pub use fonts::{FaceId, Resolved, families, resolve};
|
||||
use kurbo::{Affine, BezPath, Point};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout as layout_text, measure};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
|
@ -43,7 +45,7 @@ impl OutlinePen for Pen {
|
|||
}
|
||||
|
||||
/// Glyph outline in font units (y up), cached.
|
||||
fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = OnceLock::new();
|
||||
let c = C.get_or_init(Default::default);
|
||||
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
|
||||
|
|
@ -64,12 +66,14 @@ fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
|||
v
|
||||
}
|
||||
|
||||
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down).
|
||||
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its
|
||||
/// horizontal / vertical scale and faux italic.
|
||||
pub fn glyph_outline(g: &Glyph) -> BezPath {
|
||||
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
|
||||
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
|
||||
let slant = if g.synth_italic { 0.21 } else { 0.0 };
|
||||
Affine::new([k, 0.0, slant * k, -k, 0.0, 0.0]) * (*units).clone()
|
||||
let (hs, vs) = (g.h_scale as f64, g.v_scale as f64);
|
||||
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone()
|
||||
}
|
||||
|
||||
/// One character of laid-out text.
|
||||
|
|
@ -89,7 +93,10 @@ pub struct CharGlyph {
|
|||
/// The source character (after All Caps).
|
||||
pub ch: char,
|
||||
pub synth_bold: bool,
|
||||
/// The glyph's drawn size (superscript / small caps are smaller than the run's size).
|
||||
pub size: f64,
|
||||
/// Index into [`TextLayout::styles`].
|
||||
pub run: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -101,27 +108,111 @@ pub struct TextLayout {
|
|||
pub lines: usize,
|
||||
/// Bounds in layer space (x0, y0, x1, y1).
|
||||
pub bounds: [f64; 4],
|
||||
/// Line baselines (layer space) and widths for the viewer's text cursor.
|
||||
/// Line origins (layer space) `[x, baseline, width, height]`.
|
||||
pub line_boxes: Vec<[f64; 4]>,
|
||||
/// The character style of each run (glyphs refer to them by index).
|
||||
pub styles: Vec<CharStyle>,
|
||||
/// The paragraph layout (layout space) and its map into layer space.
|
||||
pub layout: Arc<Layout>,
|
||||
pub to_layer: Affine,
|
||||
/// Byte offset of every character, plus the text length.
|
||||
pub byte_of_char: Vec<usize>,
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
/// A character style → [`TextStyle`].
|
||||
pub fn text_style(s: &CharStyle) -> TextStyle {
|
||||
TextStyle {
|
||||
family: s.font.clone(),
|
||||
style: s.style.clone(),
|
||||
size: s.size as f32,
|
||||
tracking: s.tracking as f32,
|
||||
kerning: true,
|
||||
optical: s.kerning == Kerning::Optical,
|
||||
manual_kern: if let Kerning::Manual(v) = s.kerning { Some(v as f32) } else { None },
|
||||
ligatures: s.ligatures,
|
||||
baseline_shift: s.baseline_shift as f32,
|
||||
faux_bold: s.faux_bold,
|
||||
faux_italic: s.faux_italic,
|
||||
caps: if s.all_caps {
|
||||
Caps::All
|
||||
} else if s.small_caps {
|
||||
Caps::Small
|
||||
} else {
|
||||
Caps::Normal
|
||||
},
|
||||
underline: false,
|
||||
h_scale: (s.h_scale / 100.0) as f32,
|
||||
v_scale: (s.v_scale / 100.0) as f32,
|
||||
tsume: (s.tsume / 100.0) as f32,
|
||||
script: match s.baseline {
|
||||
BaselineOption::Normal => Script::Normal,
|
||||
BaselineOption::Superscript => Script::Super,
|
||||
BaselineOption::Subscript => Script::Sub,
|
||||
},
|
||||
leading: s.leading.map(|l| l as f32),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph settings → [`ParagraphStyle`] (wrap `width` for paragraph text).
|
||||
pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> ParagraphStyle {
|
||||
let (align, justify_last) = match p.justify {
|
||||
Justify::Left => (Align::Left, Align::Left),
|
||||
Justify::Center => (Align::Center, Align::Center),
|
||||
Justify::Right => (Align::Right, Align::Right),
|
||||
Justify::JustifyLastLeft => (Align::Justify, Align::Left),
|
||||
Justify::JustifyLastCenter => (Align::Justify, Align::Center),
|
||||
Justify::JustifyLastRight => (Align::Justify, Align::Right),
|
||||
Justify::JustifyAll => (Align::Justify, Align::Justify),
|
||||
};
|
||||
ParagraphStyle {
|
||||
align,
|
||||
justify_last,
|
||||
leading: 0.0,
|
||||
width,
|
||||
// Left-to-right paragraphs keep the Unicode default (first strong character).
|
||||
rtl: (p.direction == Direction::Rtl).then_some(true),
|
||||
indent_start: p.indent_left as f32,
|
||||
indent_end: p.indent_right as f32,
|
||||
indent_first: p.indent_first as f32,
|
||||
space_before: p.space_before as f32,
|
||||
space_after: p.space_after as f32,
|
||||
every_line: p.composer == Composer::EveryLine,
|
||||
hanging: p.hanging_punctuation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns
|
||||
/// running right to left), characters kept upright.
|
||||
const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
|
||||
/// Lay out a Source Text value in layer space. Point text: the origin is the start of the first
|
||||
/// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from
|
||||
/// `box_pos`.
|
||||
/// `box_pos`. Vertical text runs down from the origin (point) or the box's top-right corner.
|
||||
pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() };
|
||||
let (style, para) = styles(doc);
|
||||
let lay = layout_text(&text, &style, ¶);
|
||||
let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0);
|
||||
// Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0.
|
||||
let (dx, dy) = match doc.box_size {
|
||||
Some(_) => (doc.box_pos[0], doc.box_pos[1]),
|
||||
// The layout already pivots point text around x = 0 by alignment.
|
||||
None => (0.0, -first_base),
|
||||
let text = doc.text.as_str();
|
||||
let byte_of_char: Vec<usize> = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect();
|
||||
let nchars = byte_of_char.len() - 1;
|
||||
let mut runs = Vec::new();
|
||||
let mut styles = Vec::new();
|
||||
let mut ci = 0;
|
||||
for r in doc.runs() {
|
||||
let a = byte_of_char[ci.min(nchars)];
|
||||
ci += r.len;
|
||||
let b = byte_of_char[ci.min(nchars)];
|
||||
runs.push((a..b, text_style(&r.style)));
|
||||
styles.push(r.style);
|
||||
}
|
||||
let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32);
|
||||
let paras: Vec<ParagraphStyle> = doc.paras().iter().map(|p| paragraph_style(p, width)).collect();
|
||||
let lay = layout::layout_rich(text, &runs, ¶s);
|
||||
let to_layer = match (doc.vertical, doc.box_size) {
|
||||
(false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])),
|
||||
(false, None) => Affine::IDENTITY,
|
||||
(true, Some(b)) => Affine::translate((doc.box_pos[0] + b[0], doc.box_pos[1])) * ROT90,
|
||||
(true, None) => ROT90,
|
||||
};
|
||||
let hs = doc.h_scale / 100.0;
|
||||
let vs = doc.v_scale / 100.0;
|
||||
let mut out = TextLayout { lines: lay.lines.len(), ..Default::default() };
|
||||
let mut out = TextLayout { lines: lay.lines.len(), to_layer, vertical: doc.vertical, ..Default::default() };
|
||||
// Map byte clusters → char/word indices.
|
||||
let mut char_of_byte = vec![0usize; text.len() + 1];
|
||||
let mut word_of_char = Vec::new();
|
||||
|
|
@ -144,22 +235,30 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
|||
nospace += 1;
|
||||
}
|
||||
}
|
||||
out.chars = text.chars().count();
|
||||
char_of_byte[text.len()] = nchars;
|
||||
out.chars = nchars;
|
||||
out.chars_no_space = nospace;
|
||||
out.words = words;
|
||||
let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
|
||||
for (li, line) in lay.lines.iter().enumerate() {
|
||||
out.line_boxes.push([line.x as f64 + dx, line.baseline as f64 + dy, line.width as f64, (line.ascent + line.descent) as f64]);
|
||||
let o = to_layer * Point::new(line.x as f64, line.baseline as f64);
|
||||
out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]);
|
||||
for gi in line.glyphs.clone() {
|
||||
let g = &lay.glyphs[gi];
|
||||
let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0);
|
||||
let ch = text[g.cluster..].chars().next().unwrap_or(' ');
|
||||
let src = text[g.cluster..].chars().next().unwrap_or(' ');
|
||||
let st = styles.get(g.run);
|
||||
let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src };
|
||||
let mut path = glyph_outline(g);
|
||||
if hs != 1.0 || vs != 1.0 {
|
||||
path = Affine::scale_non_uniform(hs, vs) * path;
|
||||
}
|
||||
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
|
||||
let origin = Point::new(g.x as f64 * hs + dx, g.y as f64 + dy);
|
||||
let advance = (next_x - g.x) as f64;
|
||||
if doc.vertical {
|
||||
// Keep the character upright around its centre.
|
||||
let c = Point::new(advance / 2.0, -0.35 * g.size as f64);
|
||||
let rc = ROT90 * c;
|
||||
path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path;
|
||||
}
|
||||
let origin = to_layer * Point::new(g.x as f64, g.y as f64);
|
||||
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
|
||||
b[0] = b[0].min(origin.x + r.x0);
|
||||
b[1] = b[1].min(origin.y + r.y0);
|
||||
|
|
@ -169,62 +268,102 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
|||
out.glyphs.push(CharGlyph {
|
||||
path,
|
||||
origin,
|
||||
advance: (next_x - g.x) as f64 * hs,
|
||||
advance,
|
||||
char_index: ci,
|
||||
char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0),
|
||||
word_index: word_of_char.get(ci).copied().unwrap_or(0),
|
||||
line_index: li,
|
||||
is_space: ch.is_whitespace(),
|
||||
is_space: src.is_whitespace(),
|
||||
ch,
|
||||
synth_bold: g.synth_bold,
|
||||
size: g.size as f64,
|
||||
run: g.run,
|
||||
});
|
||||
}
|
||||
}
|
||||
if b[0].is_finite() {
|
||||
out.bounds = b;
|
||||
}
|
||||
out.styles = styles;
|
||||
out.byte_of_char = byte_of_char;
|
||||
out.layout = lay;
|
||||
out
|
||||
}
|
||||
|
||||
/// Character and paragraph styles of a Source Text value.
|
||||
fn styles(doc: &TextDoc) -> (TextStyle, ParagraphStyle) {
|
||||
let style = TextStyle {
|
||||
family: doc.font.clone(),
|
||||
style: doc.style.clone(),
|
||||
size: doc.size as f32,
|
||||
tracking: doc.tracking as f32,
|
||||
baseline_shift: doc.baseline_shift as f32,
|
||||
faux_bold: doc.faux_bold,
|
||||
faux_italic: doc.faux_italic,
|
||||
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
|
||||
..Default::default()
|
||||
};
|
||||
let align = match doc.justify {
|
||||
Justify::Left | Justify::JustifyLastLeft => Align::Left,
|
||||
Justify::Center | Justify::JustifyLastCenter => Align::Center,
|
||||
Justify::Right | Justify::JustifyLastRight => Align::Right,
|
||||
Justify::JustifyAll => Align::Justify,
|
||||
};
|
||||
let natural = doc.size * 1.2;
|
||||
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
|
||||
(style, ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None })
|
||||
impl TextLayout {
|
||||
/// Character index of byte offset `b`.
|
||||
pub fn char_of_byte(&self, b: usize) -> usize {
|
||||
self.byte_of_char.partition_point(|x| *x < b).min(self.chars)
|
||||
}
|
||||
fn byte(&self, ci: usize) -> usize {
|
||||
self.byte_of_char.get(ci.min(self.chars)).copied().unwrap_or(0)
|
||||
}
|
||||
/// Layout line holding caret `ci`.
|
||||
pub fn line_of_char(&self, ci: usize) -> usize {
|
||||
self.layout.line_of(self.byte(ci))
|
||||
}
|
||||
/// The caret at character boundary `ci` as a segment (top, bottom) in layer space.
|
||||
pub fn caret(&self, ci: usize) -> (Point, Point) {
|
||||
let (x, base, li) = self.layout.caret(self.byte(ci));
|
||||
let (asc, desc) = self.layout.lines.get(li).map_or((10.0, 3.0), |l| (l.ascent, l.descent));
|
||||
(self.to_layer * Point::new(x as f64, (base - asc) as f64), self.to_layer * Point::new(x as f64, (base + desc) as f64))
|
||||
}
|
||||
/// The caret's position along its line (layout space), for up / down movement.
|
||||
pub fn caret_x(&self, ci: usize) -> f64 {
|
||||
self.layout.caret(self.byte(ci)).0 as f64
|
||||
}
|
||||
/// The character boundary nearest a layer-space point.
|
||||
pub fn hit(&self, p: Point) -> usize {
|
||||
let q = self.to_layer.inverse() * p;
|
||||
self.char_of_byte(self.layout.hit(q.x as f32, q.y as f32))
|
||||
}
|
||||
/// The caret one line up (`dir` < 0) or down from `ci`, keeping the line position `goal`.
|
||||
/// Past the first / last line it goes to the start / end of the text.
|
||||
pub fn move_vertical(&self, ci: usize, dir: i32, goal: Option<f64>) -> (usize, f64) {
|
||||
let goal = goal.unwrap_or_else(|| self.caret_x(ci));
|
||||
let li = self.line_of_char(ci) as i64 + dir as i64;
|
||||
if li < 0 {
|
||||
return (0, goal);
|
||||
}
|
||||
match self.layout.lines.get(li as usize) {
|
||||
Some(l) => (self.char_of_byte(self.layout.hit_line(l, goal as f32)), goal),
|
||||
None => (self.chars, goal),
|
||||
}
|
||||
}
|
||||
/// Start and end carets of the visual line holding `ci`.
|
||||
pub fn line_span(&self, ci: usize) -> (usize, usize) {
|
||||
let li = self.line_of_char(ci);
|
||||
match self.layout.lines.get(li) {
|
||||
Some(l) => (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l))),
|
||||
None => (0, self.chars),
|
||||
}
|
||||
}
|
||||
/// Selection highlight quads (layer space) for characters `a..b`.
|
||||
pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||||
self.layout
|
||||
.selection_rects(self.byte(a.min(b)), self.byte(a.max(b)))
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let m = |x: f32, y: f32| self.to_layer * Point::new(x as f64, y as f64);
|
||||
[m(r[0], r[1]), m(r[2], r[1]), m(r[2], r[3]), m(r[0], r[3])]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline (origin on the baseline) and advance of a single character in a Source Text's
|
||||
/// style: used by Character Offset / Character Value substitutions.
|
||||
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
|
||||
let (style, _) = styles(doc);
|
||||
/// Outline (origin on the baseline) and advance of a single character in a character style:
|
||||
/// used by Character Offset / Character Value substitutions.
|
||||
pub fn char_glyph_style(s: &CharStyle, ch: char) -> (BezPath, f64) {
|
||||
let style = text_style(s);
|
||||
let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default());
|
||||
let hs = doc.h_scale / 100.0;
|
||||
let vs = doc.v_scale / 100.0;
|
||||
let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) };
|
||||
let mut path = glyph_outline(g);
|
||||
if hs != 1.0 || vs != 1.0 {
|
||||
path = Affine::scale_non_uniform(hs, vs) * path;
|
||||
}
|
||||
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0) * hs;
|
||||
(Affine::translate((-(g.x as f64) * hs, 0.0)) * path, adv)
|
||||
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0);
|
||||
(Affine::translate((-(g.x as f64), 0.0)) * glyph_outline(g), adv)
|
||||
}
|
||||
|
||||
/// [`char_glyph_style`] in a Source Text's base style.
|
||||
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
|
||||
char_glyph_style(&doc.base_style(), ch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -244,6 +383,61 @@ mod tests {
|
|||
assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_runs_carets_and_hit_testing() {
|
||||
let mut doc = TextDoc { text: "Hello big\nworld".into(), size: 40.0, ..Default::default() };
|
||||
doc.apply_style(6..9, |s| {
|
||||
s.size = 80.0;
|
||||
s.fill = [1.0, 0.0, 0.0, 1.0];
|
||||
});
|
||||
let l = layout_doc(&doc);
|
||||
let g = l.glyphs.iter().find(|g| g.char_index == 7).unwrap();
|
||||
assert_eq!(l.styles[g.run].size, 80.0);
|
||||
assert_eq!(l.styles[g.run].fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(g.size, 80.0);
|
||||
let small = l.glyphs.iter().find(|g| g.char_index == 1).unwrap();
|
||||
assert_eq!(small.origin.y, g.origin.y, "one baseline for mixed sizes");
|
||||
// Line 2 sits one 40 px auto leading (48 px) below line 1.
|
||||
let w = l.glyphs.iter().find(|g| g.char_index == 10).unwrap();
|
||||
assert!((w.origin.y - g.origin.y - 48.0).abs() < 1e-6);
|
||||
// Every caret hit-tests back to itself; carets advance along each line.
|
||||
for ci in 0..=doc.char_len() {
|
||||
let (a, b) = l.caret(ci);
|
||||
assert!(b.y > a.y);
|
||||
assert_eq!(l.hit(a.midpoint(b)), ci, "caret {ci}");
|
||||
}
|
||||
assert!(l.caret(8).0.x > l.caret(7).0.x);
|
||||
// Up / down keep the line position; line spans and selections.
|
||||
let (down, goal) = l.move_vertical(2, 1, None);
|
||||
assert_eq!(l.line_of_char(down), 1);
|
||||
assert!((goal - l.caret_x(2)).abs() < 1e-6);
|
||||
assert_eq!(l.move_vertical(down, -1, Some(goal)).0, 2);
|
||||
assert_eq!(l.move_vertical(2, -1, None).0, 0);
|
||||
assert_eq!(l.move_vertical(12, 1, None).0, 15);
|
||||
assert_eq!(l.line_span(3), (0, 9));
|
||||
assert_eq!(l.line_span(12), (10, 15));
|
||||
let q = l.selection_quads(3, 12);
|
||||
assert_eq!(q.len(), 2);
|
||||
assert_eq!(l.char_of_byte(doc.byte_of(12)), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_box_offset_and_vertical_type() {
|
||||
let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() };
|
||||
let l = layout_doc(&doc);
|
||||
let (a, _) = l.caret(0);
|
||||
assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}");
|
||||
// Vertical: characters run down a column, carets are horizontal.
|
||||
let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() });
|
||||
let ys: Vec<f64> = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect();
|
||||
assert!(ys.windows(2).all(|w| w[1] > w[0]), "{ys:?}");
|
||||
let col2 = v.glyphs.iter().find(|g| g.line_index == 1).unwrap();
|
||||
assert!(col2.origin.x < v.glyphs[0].origin.x - 10.0, "next column to the left");
|
||||
let (p0, p1) = v.caret(1);
|
||||
assert!((p0.y - p1.y).abs() < 1e-6 && (p0.x - p1.x).abs() > 5.0);
|
||||
assert_eq!(v.hit(p0.midpoint(p1)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_point_text_straddles_origin() {
|
||||
let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Zone> {
|
||||
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<PanelKind>,
|
||||
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<String> {
|
||||
fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option<String> {
|
||||
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<Floating>,
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<egui::Pos2>) -> 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<Group> = 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<DockAction>) -> Option<Group> {
|
||||
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::<PanelKind>(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::<PanelKind>(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]
|
||||
}
|
||||
|
|
@ -113,9 +113,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
x = puppet_options(app, ui, &p, x, cy);
|
||||
}
|
||||
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
|
||||
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
|
||||
app.ui.snapping = app.session.state.snapping;
|
||||
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
|
||||
app.ui.snapping = !app.ui.snapping;
|
||||
app.session.state.snapping = app.ui.snapping;
|
||||
let _ = app.session.execute("view.snapping", serde_json::json!({}));
|
||||
app.ui.snapping = app.session.state.snapping;
|
||||
}
|
||||
app.auto.add("header.snapping", snap, "Snapping");
|
||||
p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ pub enum Icon {
|
|||
Polygon,
|
||||
Star,
|
||||
Pen,
|
||||
PenAdd,
|
||||
PenDelete,
|
||||
PenConvert,
|
||||
MaskFeather,
|
||||
Type,
|
||||
TypeVertical,
|
||||
Brush,
|
||||
|
|
@ -557,6 +561,25 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
Sparkle => {
|
||||
pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]);
|
||||
}
|
||||
PenAdd | PenDelete => {
|
||||
// A small pen nib with a plus or minus beside it.
|
||||
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
|
||||
pen.line(&[(6.0, 2.5), (6.0, 7.0)]);
|
||||
pen.line(&[(10.0, 11.5), (14.5, 11.5)]);
|
||||
if icon == PenAdd {
|
||||
pen.line(&[(12.25, 9.25), (12.25, 13.75)]);
|
||||
}
|
||||
}
|
||||
PenConvert => {
|
||||
// An open caret: a corner vertex with two straight sides.
|
||||
pen.line(&[(2.5, 13.0), (8.0, 3.5), (13.5, 13.0)]);
|
||||
pen.dot(8.0, 3.5, 1.4);
|
||||
}
|
||||
MaskFeather => {
|
||||
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
|
||||
pen.line(&[(6.0, 2.5), (6.0, 7.0)]);
|
||||
pen.line(&[(10.5, 4.0), (12.5, 6.5), (13.5, 10.0), (12.5, 13.5)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u64>,
|
||||
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
|
||||
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
|
||||
/// 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,
|
||||
|
|
@ -283,7 +302,13 @@ impl EffectcraftApp {
|
|||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||||
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
||||
}
|
||||
self.ui.viewer.res.scale(zoom, ppp)
|
||||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||||
// Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging.
|
||||
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
|
||||
if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto {
|
||||
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
||||
}
|
||||
full
|
||||
}
|
||||
|
||||
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
|
||||
|
|
@ -293,8 +318,13 @@ impl EffectcraftApp {
|
|||
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
|
||||
pub fn view_hash(&self, comp: ItemId) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let Some(cam) = self.session.view_camera(comp) else { return 0 };
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
// The region of interest changes what is rendered too.
|
||||
let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp));
|
||||
if let Some(r) = roi {
|
||||
r.map(f64::to_bits).hash(&mut h);
|
||||
}
|
||||
let Some(cam) = self.session.view_camera(comp) else { return if roi.is_some() { h.finish() | 1 } else { 0 } };
|
||||
for row in cam.view.0 {
|
||||
for v in row {
|
||||
v.to_bits().hash(&mut h);
|
||||
|
|
@ -319,7 +349,10 @@ impl EffectcraftApp {
|
|||
let Some(c) = self.session.project.comp(comp) else { return };
|
||||
let key = self.frame_key(comp, frame, scale);
|
||||
let t = c.frame_rate.tick_of(frame);
|
||||
let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) };
|
||||
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
|
||||
let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp));
|
||||
let opts =
|
||||
RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview || draft, view: self.session.view_camera(comp), roi };
|
||||
if urgent {
|
||||
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
|
||||
} else {
|
||||
|
|
@ -646,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);
|
||||
}
|
||||
|
|
@ -680,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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -131,6 +136,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
// Slot shortcuts cycle through the slot's tools.
|
||||
let slot = match t {
|
||||
"shape" => Some(8),
|
||||
"pen" => Some(9),
|
||||
"type" => Some(10),
|
||||
"brush" => Some(11),
|
||||
"puppet" => Some(15),
|
||||
|
|
@ -163,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);
|
||||
|
|
@ -195,6 +253,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
|
||||
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
|
||||
"view.fastPreviews" => {
|
||||
// With a mode: Fast Previews ▸ Off / Adaptive Resolution / Draft / Fast Draft /
|
||||
// Wireframe; without: toggle Draft quality (Settings ▸ Previews).
|
||||
if params.get("mode").is_some() {
|
||||
return run_engine(app, ctx, "view.fastPreviewMode", params);
|
||||
}
|
||||
let on = !app.ui.viewer.fast_preview;
|
||||
app.set_pref("previews.fastPreviews", json!(on))?;
|
||||
app.ui.viewer.fast_preview = on;
|
||||
|
|
@ -248,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}));
|
||||
}
|
||||
|
|
@ -395,6 +462,30 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?;
|
||||
Value::Null
|
||||
}
|
||||
"view.res.custom" => {
|
||||
match p.get("factor").and_then(Value::as_u64) {
|
||||
Some(n) => {
|
||||
v.res = match n.clamp(1, 40) {
|
||||
1 => Resolution::Full,
|
||||
n => Resolution::Custom(n as u8),
|
||||
};
|
||||
}
|
||||
None => {
|
||||
let cur = match v.res {
|
||||
Resolution::Custom(n) => n as f64,
|
||||
_ => 2.0,
|
||||
};
|
||||
crate::panels::dialogs::form(
|
||||
app,
|
||||
"Custom Resolution",
|
||||
"view.res.custom",
|
||||
json!({}),
|
||||
vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)],
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"view.rulers" => toggle(&mut v.rulers, &p),
|
||||
"view.guides" => toggle(&mut v.guides, &p),
|
||||
"view.snapToGuides" => toggle(&mut v.snap_guides, &p),
|
||||
|
|
@ -468,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" => {
|
||||
|
|
@ -482,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})
|
||||
}
|
||||
|
|
@ -503,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}`"));
|
||||
}
|
||||
|
|
@ -517,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();
|
||||
}
|
||||
|
|
@ -539,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
|
||||
|
|
@ -885,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!({}));
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
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<String> = 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::<crate::panels::viewer::ViewerMap>(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<Capture> = 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<egui::Pos2> = 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>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -231,11 +231,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
|
||||
// Show Reference Graph: the other graph type, faint, scaled to the plot.
|
||||
if app.ui.timeline.graph_reference && !cs.is_empty() {
|
||||
let other = curves(app, comp, ectx, tm, plot, !speed);
|
||||
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for c in &other {
|
||||
for (_, v) in &c.pts {
|
||||
rlo = rlo.min(*v);
|
||||
rhi = rhi.max(*v);
|
||||
}
|
||||
}
|
||||
if rlo.is_finite() {
|
||||
let span = (rhi - rlo).max(1e-9);
|
||||
let lines: Vec<(Color32, Vec<Pos2>)> = other
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.color,
|
||||
c.pts
|
||||
.iter()
|
||||
.map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
super::graph_tools::reference_graph(&pc, plot, &lines);
|
||||
}
|
||||
}
|
||||
// Curves.
|
||||
for c in &cs {
|
||||
let pts: Vec<Pos2> = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect();
|
||||
pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color)));
|
||||
}
|
||||
// Transform box around several selected keys (inside below the keys, handles above).
|
||||
let sel_keys: Vec<super::graph_tools::SelKey> = cs
|
||||
.iter()
|
||||
.flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable }))
|
||||
.collect();
|
||||
let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect();
|
||||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
|
||||
// Keys and handles.
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
let fr = comp.frame_rate;
|
||||
|
|
@ -298,7 +332,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let merge = format!("gkey-{uid}-{}", k.idx);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))));
|
||||
// Graph Editor ▸ Snap: to the current time and other keys.
|
||||
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
|
||||
let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct)));
|
||||
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge});
|
||||
if !speed && c.editable {
|
||||
let mut comps = c.prop.keys[k.idx].value.components();
|
||||
|
|
@ -316,6 +353,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
}
|
||||
}
|
||||
|
||||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
|
||||
|
||||
// Background: click deselects keys, drag box-selects.
|
||||
if bg.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
|
|
@ -374,6 +413,17 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
fit_selection(app, &cs, tm, plot);
|
||||
}
|
||||
x += 6.0;
|
||||
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
|
||||
if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
|
||||
app.ui.timeline.graph_snap = !snap;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") {
|
||||
app.ui.timeline.graph_reference = !reference;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
|
||||
app.ui.timeline.graph_transform_box = !tbox;
|
||||
}
|
||||
x += 6.0;
|
||||
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| {
|
||||
let layer = comp.layer(*l)?;
|
||||
let tr = layer.transform()?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
//! Graph Editor and timeline keyframe tools: the transform box around several selected keys
|
||||
//! (scale and move them in time and value), key-time snapping, and Alt-drag of a key group's
|
||||
//! first or last key in the timeline to scale the group in time. Edits are `keys.transform`.
|
||||
|
||||
use effectcraft_engine::project::Comp;
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
type Actions = Vec<(String, Value)>;
|
||||
|
||||
/// A selected key point in the graph: comp seconds, graph value, the dimension it shows, and
|
||||
/// whether its value is editable there.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SelKey {
|
||||
pub t: f64,
|
||||
pub v: f64,
|
||||
pub dim: usize,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// Transform box geometry (data space): [t0, v0, t1, v1].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct BoxDrag {
|
||||
/// 0–3 corners (t0v1, t1v1, t1v0, t0v0 = screen TL, TR, BR, BL), 4–7 edges (top, right,
|
||||
/// bottom, left), 8 = move.
|
||||
handle: usize,
|
||||
/// Fixed opposite point (comp s, value).
|
||||
anchor: [f64; 2],
|
||||
/// Pointer data position of the previous frame.
|
||||
last: [f64; 2],
|
||||
}
|
||||
|
||||
fn box_id() -> egui::Id {
|
||||
egui::Id::new("graph-transform-box")
|
||||
}
|
||||
|
||||
/// Bounds of the selected keys: (t0, v0, t1, v1).
|
||||
fn bounds(keys: &[SelKey]) -> Option<[f64; 4]> {
|
||||
if keys.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for k in keys {
|
||||
b = [b[0].min(k.t), b[1].min(k.v), b[2].max(k.t), b[3].max(k.v)];
|
||||
}
|
||||
(b[2] > b[0] || b[3] > b[1]).then_some(b)
|
||||
}
|
||||
|
||||
fn handle_points(r: Rect) -> [Pos2; 8] {
|
||||
let c = r.center();
|
||||
[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), pos2(c.x, r.min.y), pos2(r.max.x, c.y), pos2(c.x, r.max.y), pos2(r.min.x, c.y)]
|
||||
}
|
||||
|
||||
/// The transform box for two or more selected keys. Call before the keys are drawn (its inside
|
||||
/// sits below them) with `handles = false`, and after them with `handles = true` (the handles sit
|
||||
/// on top). Dragging a corner scales time and value about the opposite corner, an edge one axis,
|
||||
/// the inside moves the keys; times land on frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn transform_box(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
keys: &[SelKey],
|
||||
tm: TMap,
|
||||
ymap: &dyn Fn(f64) -> f32,
|
||||
vmap: &dyn Fn(f32) -> f64,
|
||||
speed: bool,
|
||||
handles: bool,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
if !app.ui.timeline.graph_transform_box {
|
||||
return;
|
||||
}
|
||||
let Some(b) = bounds(keys) else { return };
|
||||
let r = Rect::from_min_max(pos2(tm.x(b[0]), ymap(b[3])), pos2(tm.x(b[2]), ymap(b[1]))).expand(6.0);
|
||||
let value_ok = !speed && keys.iter().all(|k| k.editable);
|
||||
let col = Color32::from_rgb(0xd8, 0xd8, 0xd8);
|
||||
let ctx = ui.ctx().clone();
|
||||
let drag_id = egui::Id::new("graph-dragging");
|
||||
let hp = handle_points(r);
|
||||
let data = |q: Pos2| [tm.t(q.x), vmap(q.y)];
|
||||
// Which data point each handle sits on, and its opposite.
|
||||
let corner = |i: usize| -> [f64; 2] {
|
||||
match i {
|
||||
0 => [b[0], b[3]],
|
||||
1 => [b[2], b[3]],
|
||||
2 => [b[2], b[1]],
|
||||
3 => [b[0], b[1]],
|
||||
4 => [(b[0] + b[2]) / 2.0, b[3]],
|
||||
5 => [b[2], (b[1] + b[3]) / 2.0],
|
||||
6 => [(b[0] + b[2]) / 2.0, b[1]],
|
||||
_ => [b[0], (b[1] + b[3]) / 2.0],
|
||||
}
|
||||
};
|
||||
let mut start: Option<BoxDrag> = None;
|
||||
let mut resp_any: Option<egui::Response> = None;
|
||||
if !handles {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle);
|
||||
let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag());
|
||||
app.auto.add("timeline.graph.transformBox", r, "Transform box");
|
||||
if resp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
start = Some(BoxDrag { handle: 8, anchor: [0.0; 2], last: data(o) });
|
||||
}
|
||||
resp_any = Some(resp);
|
||||
} else {
|
||||
for (i, h) in hp.iter().enumerate() {
|
||||
// Value handles only when values can change.
|
||||
if !value_ok && matches!(i, 4 | 6) {
|
||||
continue;
|
||||
}
|
||||
let hr = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
p.rect_filled(hr, 0.0, Color32::from_gray(0x20));
|
||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle);
|
||||
let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle");
|
||||
if resp.drag_started() {
|
||||
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
|
||||
start = Some(BoxDrag { handle: i, anchor: corner(opp), last: corner(i) });
|
||||
}
|
||||
if resp.dragged() || resp.drag_stopped() {
|
||||
resp_any = Some(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(box_id(), s);
|
||||
d.insert_temp(drag_id, true);
|
||||
});
|
||||
}
|
||||
let Some(resp) = resp_any else { return };
|
||||
let Some(mut st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
if (st.handle == 8) == handles {
|
||||
return;
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cur = data(pt);
|
||||
let mut params = json!({"merge": "graph-box"});
|
||||
if st.handle == 8 {
|
||||
params["timeOffset"] = json!(cur[0] - st.last[0]);
|
||||
if value_ok {
|
||||
params["valueOffset"] = json!(cur[1] - st.last[1]);
|
||||
}
|
||||
st.last = cur;
|
||||
} else {
|
||||
let (time_axis, value_axis) = match st.handle {
|
||||
4 | 6 => (false, true),
|
||||
5 | 7 => (true, false),
|
||||
_ => (true, true),
|
||||
};
|
||||
// Scale relative to where the handle is now (incremental, so merged steps compose).
|
||||
let now = corner(st.handle);
|
||||
if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 {
|
||||
let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]);
|
||||
if k > 0.01 {
|
||||
params["timeScale"] = json!(k);
|
||||
params["timeAnchor"] = json!(st.anchor[0]);
|
||||
}
|
||||
}
|
||||
if value_axis && value_ok && (now[1] - st.anchor[1]).abs() > 1e-9 {
|
||||
params["valueScale"] = json!((cur[1] - st.anchor[1]) / (now[1] - st.anchor[1]));
|
||||
params["valueAnchor"] = json!(st.anchor[1]);
|
||||
}
|
||||
}
|
||||
let mut dims: Vec<usize> = keys.iter().map(|k| k.dim).collect();
|
||||
dims.sort_unstable();
|
||||
dims.dedup();
|
||||
params["dims"] = json!(dims);
|
||||
if params.as_object().is_some_and(|m| m.len() > 2) {
|
||||
actions.push(("keys.transform".into(), params));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(box_id(), st));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<BoxDrag>(box_id());
|
||||
d.insert_temp(drag_id, false);
|
||||
});
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's
|
||||
/// time within 6 points.
|
||||
pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 {
|
||||
if !app.ui.timeline.graph_snap {
|
||||
return t;
|
||||
}
|
||||
let cti = app.session.time().seconds();
|
||||
let x = tm.x(t);
|
||||
std::iter::once(cti)
|
||||
.chain(others.iter().copied())
|
||||
.map(|c| (c, (tm.x(c) - x).abs()))
|
||||
.filter(|(_, d)| *d < 6.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(c, _)| c)
|
||||
.unwrap_or(t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline Alt-drag scaling
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AltScale {
|
||||
/// The fixed end of the group (comp s).
|
||||
anchor: f64,
|
||||
/// The dragged end is the group's last key (else its first).
|
||||
end_is_max: bool,
|
||||
}
|
||||
|
||||
fn alt_id() -> egui::Id {
|
||||
egui::Id::new("timeline-alt-scale")
|
||||
}
|
||||
|
||||
/// Selected keys' comp-time range.
|
||||
fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> {
|
||||
let ts: Vec<f64> = app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
|
||||
if ts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let lo = ts.iter().copied().fold(f64::MAX, f64::min);
|
||||
let hi = ts.iter().copied().fold(f64::MIN, f64::max);
|
||||
(hi - lo > 1e-9).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// Alt-drag on the first or last key of a selected group starts scaling the group in time
|
||||
/// about the other end. Returns true when it took the drag.
|
||||
pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool {
|
||||
let Some((lo, hi)) = sel_range(app, comp) else { return false };
|
||||
let st = if (key_ct - hi).abs() < 1e-6 {
|
||||
AltScale { anchor: lo, end_is_max: true }
|
||||
} else if (key_ct - lo).abs() < 1e-6 {
|
||||
AltScale { anchor: hi, end_is_max: false }
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), st));
|
||||
true
|
||||
}
|
||||
|
||||
/// While an Alt-drag scale is active: scale the group so its dragged end follows the pointer.
|
||||
pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, tm: TMap, actions: &mut Actions) {
|
||||
let Some(st) = ctx.data(|d| d.get_temp::<AltScale>(alt_id())) else { return };
|
||||
if !ctx.input(|i| i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| d.remove::<AltScale>(alt_id()));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
return;
|
||||
}
|
||||
let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return };
|
||||
let fr = comp.frame_rate;
|
||||
let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds();
|
||||
let end = if st.end_is_max { hi } else { lo };
|
||||
let span = end - st.anchor;
|
||||
let want = target - st.anchor;
|
||||
if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() {
|
||||
return;
|
||||
}
|
||||
let k = want / span;
|
||||
if (k - 1.0).abs() > 1e-6 {
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"})));
|
||||
}
|
||||
}
|
||||
|
||||
/// An Alt-drag group scale is in progress (the plain key move stands aside).
|
||||
pub(crate) fn alt_scale_active(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<AltScale>(alt_id())).is_some()
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Show Reference Graph: the other graph type, normalized to the plot, faint.
|
||||
pub(crate) fn reference_graph(p: &egui::Painter, plot: Rect, curves: &[(Color32, Vec<Pos2>)]) {
|
||||
for (c, pts) in curves {
|
||||
p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35))));
|
||||
}
|
||||
let _ = plot;
|
||||
}
|
||||
|
|
@ -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<u64>,
|
||||
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<MarkerRef>,
|
||||
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::<Vec<_>>()})
|
||||
} 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<u64>, 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<u64>, 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"], "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Panel bodies.
|
||||
|
||||
pub mod anim_tools;
|
||||
pub mod comp_settings;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_3d;
|
||||
|
|
@ -8,11 +9,14 @@ pub mod effects_presets;
|
|||
pub mod forms;
|
||||
pub mod fx_widgets;
|
||||
pub mod graph;
|
||||
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;
|
||||
|
|
@ -23,6 +27,9 @@ pub mod text_panels;
|
|||
pub mod timeline;
|
||||
pub mod tracker;
|
||||
pub mod viewer;
|
||||
pub mod viewer_overlays;
|
||||
pub mod viewer_text;
|
||||
pub mod viewer_tools;
|
||||
pub mod waveform;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
|
@ -74,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),
|
||||
|
|
@ -93,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();
|
||||
|
|
|
|||
|
|
@ -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<ItemId> {
|
||||
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<ItemId> {
|
||||
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<ItemId> {
|
||||
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<u64>,
|
||||
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<u64> = 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<ItemId> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u64> {
|
|||
}
|
||||
}
|
||||
|
||||
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<ItemId>) -> Option<ItemId> {
|
||||
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<egui::TextureHandle> {
|
||||
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::<Editing>(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::<Editing>(edit_id())).is_none()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
{
|
||||
begin_rename(app, &ctx);
|
||||
}
|
||||
let editing: Option<Editing> = ctx.data(|d| d.get_temp(edit_id()));
|
||||
let dragging = egui::DragAndDrop::payload::<DragPayload>(&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<ItemId> = 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::<Editing>(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::<Editing>(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<String> = 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<u64> = 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::<Editing>(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::<Editing>(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::<Editing>(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<u64> = 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::<DragPayload>(&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<String>, 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"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,8 +107,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------- Text / Paragraph / Animation
|
||||
// While the layer's text is edited in the viewer, the text sections show and change the
|
||||
// selected characters.
|
||||
let text_target = crate::panels::text_panels::text_target(app).filter(|tt| tt.layer == layer.id.0);
|
||||
if matches!(layer.source, LayerSource::Text)
|
||||
&& let Some(doc) = effectcraft_engine::render::text::source_text(&ectx, &layer)
|
||||
&& let Some(doc) = text_target.as_ref().map(|tt| tt.doc.clone()).or_else(|| effectcraft_engine::render::text::source_text(&ectx, &layer))
|
||||
{
|
||||
divider(&p, x0, w, y - 4.0, &t);
|
||||
y = text_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
|
||||
|
|
@ -166,6 +169,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6));
|
||||
}
|
||||
for (id, params) in actions {
|
||||
let params = match &text_target {
|
||||
Some(tt) if id == "layer.setText" => tt.params(params),
|
||||
_ => params,
|
||||
};
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
//! Character, Paragraph and Align panels.
|
||||
//! Character, Paragraph and Align panels. While a text layer is edited in the viewer the
|
||||
//! Character and Paragraph panels show and change the selected text (creating style runs);
|
||||
//! otherwise they apply to the whole selected text layer.
|
||||
|
||||
use effectcraft_engine::keyframe::{Justify, TextDoc};
|
||||
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, Justify, Kerning, TextDoc};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
|
@ -9,10 +11,44 @@ use crate::icons::{self, Icon};
|
|||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
/// The selected text layer's Source Text at the CTI.
|
||||
fn text_doc(app: &EffectcraftApp) -> Option<(u64, TextDoc)> {
|
||||
/// What the text panels act on: a text layer, a document whose base style and first paragraph
|
||||
/// show the selection's formatting, and the selected character range while editing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextTarget {
|
||||
pub layer: u64,
|
||||
pub doc: TextDoc,
|
||||
pub range: Option<[usize; 2]>,
|
||||
}
|
||||
|
||||
impl TextTarget {
|
||||
/// `layer.setText` params for this target.
|
||||
pub fn params(&self, mut v: serde_json::Value) -> serde_json::Value {
|
||||
v["layer"] = json!(self.layer);
|
||||
if let Some(r) = self.range {
|
||||
v["range"] = json!(r);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// The edited text layer's selection, else the selected text layer's Source Text at the CTI.
|
||||
pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
|
||||
let comp = app.session.active_comp()?;
|
||||
let cid = app.session.active_comp_id()?;
|
||||
if let Some(e) = app.session.state.text_edit.clone()
|
||||
&& let Some(full) = effectcraft_engine::commands::text_edit::layer_doc(&app.session, e.layer)
|
||||
{
|
||||
let r = e.range();
|
||||
let style = e.pending.clone().unwrap_or_else(|| if r.is_empty() { full.insertion_style(r.start) } else { full.style_at(r.start) });
|
||||
let para = full.para(full.para_of(r.start));
|
||||
let mut view = full.clone();
|
||||
view.runs.clear();
|
||||
view.paragraphs.clear();
|
||||
view.apply_style_all(|s| *s = style.clone());
|
||||
let n = view.para_count();
|
||||
view.set_paras(vec![para; n]);
|
||||
return Some(TextTarget { layer: e.layer.0, doc: view, range: Some([r.start, r.end]) });
|
||||
}
|
||||
let layer = app
|
||||
.session
|
||||
.state
|
||||
|
|
@ -21,15 +57,36 @@ fn text_doc(app: &EffectcraftApp) -> Option<(u64, TextDoc)> {
|
|||
.filter_map(|id| comp.layer(*id))
|
||||
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref() };
|
||||
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| (layer.id.0, d))
|
||||
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
|
||||
}
|
||||
|
||||
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::new(("text-toggle", id)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
let font = if bold { Tokens::semibold(13.0) } else { Tokens::ui(12.0) };
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, font, if on { egui::Color32::WHITE } else { t.text });
|
||||
app.auto.add(id, r, label);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let (lid, doc) = text_doc(app).unwrap_or((0, TextDoc::default()));
|
||||
let enabled = lid != 0;
|
||||
let target = text_target(app);
|
||||
let enabled = target.is_some();
|
||||
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let x0 = rect.min.x + 10.0;
|
||||
let w = rect.width() - 20.0;
|
||||
|
|
@ -57,16 +114,18 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(json!({"style": styles[i]}));
|
||||
}
|
||||
y += 34.0;
|
||||
// Numeric fields in AE's two-column grid.
|
||||
let fields: [(&str, &str, f64, (f64, f64), &str); 8] = [
|
||||
// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).
|
||||
let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
|
||||
("size", "T", doc.size, (1.0, 2000.0), " px"),
|
||||
("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
|
||||
("", "", 0.0, (0.0, 0.0), ""),
|
||||
("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
|
||||
("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
|
||||
("", "", 0.0, (0.0, 0.0), ""),
|
||||
("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
|
||||
("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
|
||||
("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
|
||||
("", "", 0.0, (0.0, 0.0), ""),
|
||||
("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
|
||||
];
|
||||
for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
|
||||
if key.is_empty() {
|
||||
|
|
@ -85,10 +144,35 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(json!({key: nv, "merge": format!("char-{key}")}));
|
||||
}
|
||||
}
|
||||
// Stroke Over Fill / Fill Over Stroke (bottom-right cell).
|
||||
// Kerning: Metrics / Optical / a manual value (row 1, left).
|
||||
{
|
||||
let fy = y + 28.0;
|
||||
p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
|
||||
let label = match doc.kerning {
|
||||
Kerning::Metrics => "Metrics".to_string(),
|
||||
Kerning::Optical => "Optical".to_string(),
|
||||
Kerning::Manual(v) => format!("{v:.0}"),
|
||||
};
|
||||
let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
|
||||
let kpop = egui::Id::new("char-kerning-pop");
|
||||
if widgets::dropdown(ui, kr, &label, &t, egui::Id::new("char-kerning")).clicked() && enabled {
|
||||
widgets::open_popup(ui, kpop);
|
||||
}
|
||||
app.auto.add("character.kerning", kr, "Kerning");
|
||||
let opts: Vec<String> = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
|
||||
let v = match i {
|
||||
0 => json!("metrics"),
|
||||
1 => json!("optical"),
|
||||
_ => json!(opts[i].parse::<f64>().unwrap_or(0.0)),
|
||||
};
|
||||
actions.push(json!({"kerning": v}));
|
||||
}
|
||||
}
|
||||
// Stroke Over Fill / Fill Over Stroke (row 2, right).
|
||||
{
|
||||
let fx = x0 + w / 2.0;
|
||||
let fy = y + 3.0 * 28.0;
|
||||
let fy = y + 2.0 * 28.0;
|
||||
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
|
||||
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
|
||||
let pop = egui::Id::new("char-stroke-order-pop");
|
||||
|
|
@ -100,80 +184,126 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(json!({"strokeOverFill": i == 1}));
|
||||
}
|
||||
}
|
||||
y += 4.0 * 28.0 + 8.0;
|
||||
// Fill / stroke swatches.
|
||||
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "Fill", Tokens::ui(11.5), t.text_dim);
|
||||
let fs = Rect::from_min_size(pos2(x0 + 34.0, y), vec2(26.0, 20.0));
|
||||
if widgets::swatch(ui, fs, doc.fill, egui::Id::new("char-fill"), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, egui::Id::new("char-fill-pop"));
|
||||
}
|
||||
app.auto.add("character.fill", fs, "Fill color");
|
||||
let mut fc = [doc.fill[0], doc.fill[1], doc.fill[2]];
|
||||
if crate::header::color_popup(ui, egui::Id::new("char-fill-pop"), fs.left_bottom(), &mut fc) {
|
||||
actions.push(json!({"fill": [fc[0], fc[1], fc[2]], "merge": "char-fill"}));
|
||||
}
|
||||
p.text(pos2(x0 + 80.0, y + 10.0), Align2::LEFT_CENTER, "Stroke", Tokens::ui(11.5), t.text_dim);
|
||||
let ss = Rect::from_min_size(pos2(x0 + 126.0, y), vec2(26.0, 20.0));
|
||||
if widgets::swatch(ui, ss, doc.stroke, egui::Id::new("char-stroke"), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, egui::Id::new("char-stroke-pop"));
|
||||
}
|
||||
app.auto.add("character.stroke", ss, "Stroke color");
|
||||
let mut sc = [doc.stroke[0], doc.stroke[1], doc.stroke[2]];
|
||||
if crate::header::color_popup(ui, egui::Id::new("char-stroke-pop"), ss.left_bottom(), &mut sc) {
|
||||
actions.push(json!({"stroke": [sc[0], sc[1], sc[2]], "merge": "char-stroke"}));
|
||||
y += 5.0 * 28.0 + 8.0;
|
||||
// Fill / stroke swatches with their enable boxes.
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let fx = x0 + i as f32 * (w / 2.0);
|
||||
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled {
|
||||
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
|
||||
}
|
||||
app.auto.add(&format!("character.{key}.enabled"), cr, label);
|
||||
let fs = Rect::from_min_size(pos2(fx + 20.0, y), vec2(26.0, 20.0));
|
||||
let pop = egui::Id::new(("char-color-pop", key));
|
||||
if widgets::swatch(ui, fs, c, egui::Id::new(("char-color", key)), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("character.{key}"), fs, label);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) {
|
||||
actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")}));
|
||||
}
|
||||
p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
y += 32.0;
|
||||
// Style toggles.
|
||||
let toggles =
|
||||
[("fauxBold", "T", doc.faux_bold), ("fauxItalic", "T", doc.faux_italic), ("allCaps", "TT", doc.all_caps), ("smallCaps", "Tт", doc.small_caps)];
|
||||
for (i, (key, label, on)) in toggles.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0, y), vec2(30.0, 24.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("char-t", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
let font = if key == "fauxBold" { Tokens::semibold(13.0) } else { Tokens::ui(12.0) };
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, font, if on { egui::Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("character.{key}"), r, key);
|
||||
if resp.clicked() && enabled {
|
||||
// Style toggles: Faux Bold, Faux Italic, All Caps, Small Caps, Superscript, Subscript.
|
||||
let sup = doc.baseline == BaselineOption::Superscript;
|
||||
let sub = doc.baseline == BaselineOption::Subscript;
|
||||
let toggles = [
|
||||
("fauxBold", "T", doc.faux_bold, true),
|
||||
("fauxItalic", "T", doc.faux_italic, false),
|
||||
("allCaps", "TT", doc.all_caps, false),
|
||||
("smallCaps", "Tт", doc.small_caps, false),
|
||||
("superscript", "T¹", sup, false),
|
||||
("subscript", "T₁", sub, false),
|
||||
];
|
||||
let bw = ((w - 5.0 * 4.0) / 6.0).clamp(22.0, 30.0);
|
||||
for (i, (key, label, on, bold)) in toggles.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y), vec2(bw, 24.0));
|
||||
if toggle(app, ui, &p, r, &format!("character.{key}"), label, on, bold) && enabled {
|
||||
actions.push(json!({key: !on}));
|
||||
}
|
||||
}
|
||||
if !enabled {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
|
||||
y += 32.0;
|
||||
// Ligatures.
|
||||
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled {
|
||||
actions.push(json!({"ligatures": !doc.ligatures}));
|
||||
}
|
||||
for mut a in actions {
|
||||
a["layer"] = json!(lid);
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", a) {
|
||||
app.auto.add("character.ligatures", lr, "Ligatures");
|
||||
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim);
|
||||
match &target {
|
||||
None => {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
Some(tt) if tt.range.is_some() => {
|
||||
let [a, b] = tt.range.unwrap_or_default();
|
||||
let msg = if a == b { "Editing: caret (applies to typed text)".to_string() } else { format!("Editing: {} characters selected", b - a) };
|
||||
p.text(pos2(x0, rect.max.y - 14.0), Align2::LEFT_CENTER, msg, Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(tt) = target else { return };
|
||||
for a in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", tt.params(a)) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The seven Paragraph panel alignment buttons: (justify, `layer.setText` key).
|
||||
const ALIGNS: [(Justify, &str); 7] = [
|
||||
(Justify::Left, "left"),
|
||||
(Justify::Center, "center"),
|
||||
(Justify::Right, "right"),
|
||||
(Justify::JustifyLastLeft, "justifyLeft"),
|
||||
(Justify::JustifyLastCenter, "justifyCenter"),
|
||||
(Justify::JustifyLastRight, "justifyRight"),
|
||||
(Justify::JustifyAll, "justifyAll"),
|
||||
];
|
||||
|
||||
/// Lines glyph of an alignment button.
|
||||
pub fn paint_justify_glyph(p: &egui::Painter, r: Rect, j: Justify, c: egui::Color32) {
|
||||
for k in 0..4 {
|
||||
let ly = r.min.y + 6.0 + k as f32 * 4.0;
|
||||
let full = r.width() - 10.0;
|
||||
let last = k == 3;
|
||||
let justified = !matches!(j, Justify::Left | Justify::Center | Justify::Right);
|
||||
let lw = if (justified && !last) || j == Justify::JustifyAll {
|
||||
full
|
||||
} else if k % 2 == 1 || last {
|
||||
full * 0.6
|
||||
} else {
|
||||
full
|
||||
};
|
||||
let anchor = match j {
|
||||
Justify::Center | Justify::JustifyLastCenter => 1,
|
||||
Justify::Right | Justify::JustifyLastRight => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let lx = match anchor {
|
||||
1 => r.center().x - lw / 2.0,
|
||||
2 => r.max.x - 5.0 - lw,
|
||||
_ => r.min.x + 5.0,
|
||||
};
|
||||
p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], egui::Stroke::new(1.4, c));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let (lid, doc) = text_doc(app).unwrap_or((0, TextDoc::default()));
|
||||
let target = text_target(app);
|
||||
let enabled = target.is_some();
|
||||
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
|
||||
let x0 = rect.min.x + 10.0;
|
||||
let y = rect.min.y + 10.0;
|
||||
let opts = [
|
||||
(Justify::Left, "left"),
|
||||
(Justify::Center, "center"),
|
||||
(Justify::Right, "right"),
|
||||
(Justify::JustifyLastLeft, "justify"),
|
||||
(Justify::JustifyAll, "justifyAll"),
|
||||
];
|
||||
for (i, (j, key)) in opts.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 32.0, y), vec2(28.0, 26.0));
|
||||
let w = rect.width() - 20.0;
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let mut actions: Vec<serde_json::Value> = vec![];
|
||||
let bw = ((w - 6.0 * 3.0) / 7.0).clamp(20.0, 28.0);
|
||||
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 3.0), y), vec2(bw, 24.0));
|
||||
let on = doc.justify == j;
|
||||
let resp = ui.interact(r, egui::Id::new(("para", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
|
|
@ -187,26 +317,77 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
t.field_bg
|
||||
},
|
||||
);
|
||||
let c = if on { egui::Color32::WHITE } else { t.text };
|
||||
for k in 0..4 {
|
||||
let ly = r.min.y + 7.0 + k as f32 * 4.0;
|
||||
let full = r.width() - 12.0;
|
||||
let lw = if k % 2 == 1 && !matches!(j, Justify::JustifyAll) { full * 0.65 } else { full };
|
||||
let lx = match j {
|
||||
Justify::Center => r.center().x - lw / 2.0,
|
||||
Justify::Right => r.max.x - 6.0 - lw,
|
||||
_ => r.min.x + 6.0,
|
||||
};
|
||||
p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], egui::Stroke::new(1.4, c));
|
||||
}
|
||||
paint_justify_glyph(&p, r, j, if on { egui::Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("paragraph.{key}"), r, key);
|
||||
if resp.clicked() && lid != 0 {
|
||||
let _ = crate::menus::invoke(app, &ctx, "layer.setText", json!({"layer": lid, "justify": key}));
|
||||
if resp.clicked() && enabled {
|
||||
actions.push(json!({"justify": key}));
|
||||
}
|
||||
}
|
||||
if lid == 0 {
|
||||
y += 34.0;
|
||||
// Indents and spacing (AE's two-column grid).
|
||||
let fields: [(&str, &str, f64); 6] = [
|
||||
("indentLeft", "→|", doc.indent_left),
|
||||
("indentRight", "|←", doc.indent_right),
|
||||
("indentFirst", "→¶", doc.indent_first),
|
||||
("", "", 0.0),
|
||||
("spaceBefore", "↑¶", doc.space_before),
|
||||
("spaceAfter", "¶↓", doc.space_after),
|
||||
];
|
||||
for (i, (key, glyph, v)) in fields.into_iter().enumerate() {
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fx = x0 + (i % 2) as f32 * (w / 2.0);
|
||||
let fy = y + (i / 2) as f32 * 28.0;
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::new(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
|
||||
app.auto.add(&format!("paragraph.{key}"), r, key);
|
||||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
{
|
||||
actions.push(json!({key: nv, "merge": format!("para-{key}")}));
|
||||
}
|
||||
}
|
||||
y += 3.0 * 28.0 + 6.0;
|
||||
// Direction and composer popups.
|
||||
let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0));
|
||||
let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()];
|
||||
let dpop = egui::Id::new("para-direction-pop");
|
||||
let rtl = doc.direction == Direction::Rtl;
|
||||
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled {
|
||||
widgets::open_popup(ui, dpop);
|
||||
}
|
||||
app.auto.add("paragraph.direction", dr, "Text direction");
|
||||
if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) {
|
||||
actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }}));
|
||||
}
|
||||
let cr = Rect::from_min_size(pos2(x0 + w / 2.0, y), vec2(w / 2.0, 22.0));
|
||||
let comps = vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()];
|
||||
let cpop = egui::Id::new("para-composer-pop");
|
||||
let single = doc.composer == Composer::SingleLine;
|
||||
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled {
|
||||
widgets::open_popup(ui, cpop);
|
||||
}
|
||||
app.auto.add("paragraph.composer", cr, "Composer");
|
||||
if let Some(i) = widgets::popup_menu(ui, cpop, cr.left_bottom(), &comps, Some(single as usize)) {
|
||||
actions.push(json!({"composer": if i == 1 { "singleLine" } else { "everyLine" }}));
|
||||
}
|
||||
y += 30.0;
|
||||
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled {
|
||||
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
|
||||
}
|
||||
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
|
||||
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Roman Hanging Punctuation", Tokens::ui(11.5), t.text_dim);
|
||||
if !enabled {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
let Some(tt) = target else { return };
|
||||
for a in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", tt.params(a)) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Align panel: align selected layers to the composition (or to the selection).
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
@ -1352,7 +1337,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if kresp.dragged() {
|
||||
// Alt-drag the first or last key of a selected group: scale the group in time.
|
||||
if kresp.drag_started()
|
||||
&& ui.input(|i| i.modifiers.alt)
|
||||
&& let Some(c) = app.session.active_comp().cloned()
|
||||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
let alt_scaling = super::graph_tools::alt_scale_active(&ctx);
|
||||
if kresp.dragged() && !alt_scaling {
|
||||
let acc_id = egui::Id::new("key-drag-acc");
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += kresp.drag_delta().x;
|
||||
|
|
@ -1396,6 +1389,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Alt-drag key group scaling (follows the pointer until release).
|
||||
if let Some(c) = app.session.active_comp().cloned() {
|
||||
super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions);
|
||||
}
|
||||
// Graph editor.
|
||||
if graph_on {
|
||||
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
|
||||
|
|
@ -1489,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::<crate::panels::DragPayload>(&ctx)
|
||||
&& ui.rect_contains_pointer(rows_rect)
|
||||
|
|
@ -1708,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]}));
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@
|
|||
//! camera/light wireframes.
|
||||
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
|
||||
use effectcraft_engine::project::{Layer, LayerId};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::icons::Icon;
|
||||
use crate::state::{Resolution, Tool};
|
||||
use crate::state::Tool;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
|
|
@ -62,6 +61,8 @@ enum Gesture {
|
|||
Move {
|
||||
layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>,
|
||||
start: [f64; 2],
|
||||
/// The grabbed layer's feature nearest the pointer (comp px at the start): it snaps.
|
||||
snap_src: Option<[f64; 2]>,
|
||||
},
|
||||
Camera {
|
||||
tool: Tool,
|
||||
|
|
@ -96,10 +97,14 @@ enum Gesture {
|
|||
Marquee {
|
||||
start: Pos2,
|
||||
},
|
||||
/// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer).
|
||||
/// Dragging selected mask / shape path vertices: press point, the grabbed vertex (comp px,
|
||||
/// it snaps), the comp-space move applied so far and comp → path space.
|
||||
Vertices {
|
||||
last: [f64; 2],
|
||||
start: [f64; 2],
|
||||
src: [f64; 2],
|
||||
applied: [f64; 2],
|
||||
inv: Mat3,
|
||||
layer: LayerId,
|
||||
},
|
||||
/// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them).
|
||||
Tangent {
|
||||
|
|
@ -131,6 +136,9 @@ enum Gesture {
|
|||
tracker: u64,
|
||||
drag: super::tracker::Drag,
|
||||
},
|
||||
/// Motion paths, free transform, Mask Feather, guides and the region of interest
|
||||
/// (`viewer_overlays`).
|
||||
Overlay(ov::Drag),
|
||||
}
|
||||
|
||||
/// Pen tool path in progress: (layer, mask uid).
|
||||
|
|
@ -138,19 +146,8 @@ fn pen_id() -> egui::Id {
|
|||
egui::Id::new("viewer-pen")
|
||||
}
|
||||
|
||||
/// Mask vertex / tangent hit targets drawn this frame.
|
||||
#[derive(Clone, Copy)]
|
||||
struct VertexHit {
|
||||
layer: LayerId,
|
||||
mask: u64,
|
||||
index: usize,
|
||||
pos: Pos2,
|
||||
/// None = the vertex itself, Some(out?) = a tangent handle.
|
||||
tangent: Option<bool>,
|
||||
/// Layer-space vertex position and layer → comp matrix.
|
||||
vertex: [f64; 2],
|
||||
l2c: Mat3,
|
||||
}
|
||||
use super::viewer_overlays::{self as ov, VertexHit};
|
||||
use super::viewer_tools as vt;
|
||||
|
||||
const HANDLE: f32 = 7.0;
|
||||
|
||||
|
|
@ -356,6 +353,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// keeps the overlays and the interaction.
|
||||
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
|
||||
p.rect_filled(area, 0.0, pasteboard);
|
||||
// View ▸ Show Rulers takes a strip on the top and left.
|
||||
let outer = area;
|
||||
let area = vt::inset(app, area);
|
||||
|
||||
let (cw, ch) = (comp.width as f32, comp.height as f32);
|
||||
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
|
||||
|
|
@ -391,6 +391,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)),
|
||||
}
|
||||
app.viewer_image = Some(img);
|
||||
vt::set_texture_roi(&ctx, app.session.state.region_of_interest);
|
||||
}
|
||||
}
|
||||
if app.ui.viewer.transparency_grid {
|
||||
|
|
@ -399,11 +400,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let bg = comp.background;
|
||||
painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
|
||||
}
|
||||
if let Some((tex, k)) = &app.viewer_tex
|
||||
&& k.comp == cid.0
|
||||
{
|
||||
painter.image(tex.id(), comp_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
|
||||
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
|
||||
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
|
||||
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
|
||||
app.auto.add("viewer.comp", comp_rect, &comp_name);
|
||||
app.auto.add("viewer.area", area, "Composition viewer");
|
||||
|
|
@ -452,6 +453,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
vt::proportional_grid(app, &painter, comp_rect);
|
||||
|
||||
// Comp guides (View ▸ Show Guides) and the region of interest.
|
||||
if app.ui.viewer.guides {
|
||||
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
|
||||
|
|
@ -469,110 +472,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
|
||||
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
|
||||
painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
}
|
||||
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
|
||||
}
|
||||
|
||||
// Overlays + interaction.
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
|
||||
let mut vertex_hits: Vec<VertexHit> = Vec::new();
|
||||
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
|
||||
let mut paths: Vec<ov::PathInfo> = Vec::new();
|
||||
let sel_vertices = app.session.state.selected_vertices.clone();
|
||||
// Pen path in progress ends when the tool changes or its mask is gone.
|
||||
if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) {
|
||||
// Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
|
||||
// free transform.
|
||||
let finish = ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input();
|
||||
if app.ui.tool != Tool::Pen || finish {
|
||||
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
|
||||
}
|
||||
if finish || app.ui.tool != Tool::Selection {
|
||||
ov::end_free_transform(&ctx);
|
||||
}
|
||||
if app.ui.viewer.show_layer_controls {
|
||||
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
|
||||
// Layer Handles and Paths.
|
||||
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
|
||||
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
|
||||
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
|
||||
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
|
||||
let col = if label_handles { t.label(l.label) } else { t.accent };
|
||||
// Motion path of animated position.
|
||||
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
|
||||
&& pos.keys.len() > 1
|
||||
&& l.parent.is_none()
|
||||
{
|
||||
let lt0 = pos.keys.first().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
let lt1 = pos.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
let fd = comp.frame_duration();
|
||||
let mut pts = vec![];
|
||||
let mut lt = lt0;
|
||||
while lt <= lt1 {
|
||||
let v = pos.value_at(lt).as_vec2();
|
||||
pts.push(map.to_screen(v));
|
||||
lt += fd;
|
||||
}
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, col.gamma_multiply(0.8))));
|
||||
for q in pts.iter().step_by(1) {
|
||||
painter.circle_filled(*q, 1.2, col.gamma_multiply(0.8));
|
||||
}
|
||||
for k in &pos.keys {
|
||||
let v = k.value.as_vec2();
|
||||
let s = map.to_screen(v);
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, col);
|
||||
}
|
||||
}
|
||||
// Masks.
|
||||
if app.ui.viewer.show_masks
|
||||
&& let Some(masks) = l.masks()
|
||||
{
|
||||
let (m, _) = l2c(&ectx, l);
|
||||
for g in masks.groups() {
|
||||
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
|
||||
if locked && app.session.state.hide_locked_masks {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue };
|
||||
let Some(sp) = path.as_path() else { continue };
|
||||
let k = effectcraft_engine::render::kurbo_path(sp);
|
||||
let mut pts: Vec<Pos2> = vec![];
|
||||
kurbo::flatten(k.iter(), 0.5, |el| match el {
|
||||
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
|
||||
let c = m.apply(gv2(q.x, q.y));
|
||||
pts.push(map.to_screen([c.x, c.y]));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if sp.closed && !pts.is_empty() {
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
let mc = Color32::from_rgb(color[0], color[1], color[2]);
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc)));
|
||||
let scr = |p: [f64; 2]| {
|
||||
let c = m.apply(gv2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
for (i, v) in sp.vertices.iter().enumerate() {
|
||||
let s = scr(*v);
|
||||
let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i);
|
||||
// Selected vertices: filled with their tangent handles; others hollow.
|
||||
if selected {
|
||||
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
|
||||
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
|
||||
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, mc));
|
||||
painter.circle_filled(h, 3.0, mc);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
|
||||
}
|
||||
// Pen rubber band from the last vertex to the pointer.
|
||||
if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid))
|
||||
&& let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos()))
|
||||
{
|
||||
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
|
||||
}
|
||||
}
|
||||
// Motion path of animated position (keyframes and spatial tangents are draggable).
|
||||
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits);
|
||||
// Masks and shape paths.
|
||||
if app.ui.viewer.show_masks {
|
||||
let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths);
|
||||
}
|
||||
let Some((m, q, _)) = layer_quad(&ectx, l) else {
|
||||
// Cameras, lights, empty layers: just the anchor.
|
||||
|
|
@ -635,6 +570,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
|
||||
}
|
||||
|
||||
// Free transform box of a path (double-click a path with the Selection tool).
|
||||
let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
|
||||
|
||||
// Cameras and lights (wireframes) and the 3D view name.
|
||||
if comp.has_3d() {
|
||||
draw_rigs(app, &painter, &map, &ectx, &selected);
|
||||
|
|
@ -677,7 +615,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
Tool::Orbit => egui::CursorIcon::Alias,
|
||||
Tool::PanCamera => egui::CursorIcon::Move,
|
||||
Tool::Dolly => egui::CursorIcon::ResizeVertical,
|
||||
t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair,
|
||||
t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair,
|
||||
_ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair,
|
||||
t if t.puppet_kind().is_some() => {
|
||||
if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) {
|
||||
egui::CursorIcon::Move
|
||||
|
|
@ -686,8 +625,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
_ => {
|
||||
if super::tracker::hit_at(&track_hits, hp).is_some() {
|
||||
if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
|
||||
egui::CursorIcon::Move
|
||||
} else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
|
||||
if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
|
||||
} else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else {
|
||||
|
|
@ -711,6 +652,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
} else {
|
||||
app.pointer_comp = None;
|
||||
}
|
||||
app.ui.viewer.interacting = resp.dragged();
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input();
|
||||
// Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the
|
||||
|
|
@ -721,7 +663,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
&& ui.input(|i| i.pointer.primary_pressed())
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
pen_press(app, ui, &ectx, &map, &vertex_hits, pos);
|
||||
pen_press(app, ui, &ectx, &map, &paths, pos, mods);
|
||||
}
|
||||
// A pen click (press + release without a drag) ends its tangent gesture on release.
|
||||
if ui.input(|i| i.pointer.primary_released())
|
||||
|
|
@ -733,7 +675,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
|
||||
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied()
|
||||
};
|
||||
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
|
||||
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
|
||||
if resp.drag_started()
|
||||
&& !text_owns
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
|
|
@ -741,6 +686,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool };
|
||||
let g = match tool {
|
||||
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
|
||||
// Region of Interest armed: the drag draws it.
|
||||
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })),
|
||||
Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => {
|
||||
ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay)
|
||||
}
|
||||
Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
|
||||
ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
|
||||
}
|
||||
Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
|
||||
vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
|
||||
}
|
||||
// Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
|
||||
// new tangents out of it.
|
||||
Tool::Selection | Tool::PenConvert if (mods.alt || tool == Tool::PenConvert) && vertex_at(press, false).is_some() => {
|
||||
vertex_at(press, false).map(|h| {
|
||||
let inv = h.l2c.inverse().unwrap_or(Mat3::IDENTITY);
|
||||
Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
|
||||
})
|
||||
}
|
||||
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
|
||||
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
|
||||
let layer = comp.layer(l).cloned();
|
||||
let center = layer
|
||||
|
|
@ -794,7 +759,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if !selected {
|
||||
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift}));
|
||||
}
|
||||
Gesture::Vertices { last: cpt, inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY) }
|
||||
let src = h.l2c.apply(gv2(h.vertex[0], h.vertex[1]));
|
||||
Gesture::Vertices {
|
||||
start: map.to_comp(press),
|
||||
src: [src.x, src.y],
|
||||
applied: [0.0; 2],
|
||||
inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY),
|
||||
layer: h.layer,
|
||||
}
|
||||
}),
|
||||
Tool::Selection => {
|
||||
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() {
|
||||
|
|
@ -822,10 +794,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
Some((l.id, p0, ax, ay))
|
||||
})
|
||||
.collect();
|
||||
let _ = l;
|
||||
(!layers.is_empty()).then_some(Gesture::Move { layers, start: cpt })
|
||||
// Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
|
||||
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
|
||||
let c = map.to_comp(press);
|
||||
f.into_iter().min_by(|a, b| {
|
||||
let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
|
||||
let db = (b[0] - c[0]).powi(2) + (b[1] - c[1]).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
});
|
||||
(!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src })
|
||||
} else {
|
||||
let _ = app.session.execute("edit.deselectAll", json!({}));
|
||||
// With path vertices on screen the marquee selects vertices (layers stay
|
||||
// selected); otherwise it starts from a clean selection.
|
||||
if !vertex_hits.iter().any(|h| h.tangent.is_none()) {
|
||||
let _ = app.session.execute("edit.deselectAll", json!({}));
|
||||
}
|
||||
Some(Gesture::Marquee { start: pos })
|
||||
}
|
||||
}
|
||||
|
|
@ -844,11 +828,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let d = resp.total_drag_delta().unwrap_or_default();
|
||||
app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y];
|
||||
}
|
||||
Gesture::Move { layers, start } => {
|
||||
Gesture::Move { layers, start, snap_src } => {
|
||||
let mut d = [cpt[0] - start[0], cpt[1] - start[1]];
|
||||
if mods.shift {
|
||||
if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 }
|
||||
}
|
||||
if let Some(src) = snap_src {
|
||||
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
|
||||
let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
|
||||
d = [d[0] + c[0], d[1] + c[1]];
|
||||
}
|
||||
for (lid, p0, ax, ay) in layers {
|
||||
let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
|
||||
|
|
@ -937,13 +926,33 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let r = Rect::from_two_pos(a, pos);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||||
}
|
||||
Gesture::Vertices { last, inv } => {
|
||||
let d = inv.apply_vec(gv2(cpt[0] - last[0], cpt[1] - last[1]));
|
||||
Gesture::Vertices { start, src, applied, inv, layer } => {
|
||||
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
|
||||
let want = [cpt[0] - start[0], cpt[1] - start[1]];
|
||||
let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
|
||||
let total = [want[0] + c[0], want[1] + c[1]];
|
||||
let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
|
||||
if d.x != 0.0 || d.y != 0.0 {
|
||||
let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge}));
|
||||
}
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { last: cpt, inv }));
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { start, src, applied: total, inv, layer }));
|
||||
}
|
||||
Gesture::Overlay(d) => match d {
|
||||
ov::Drag::Guide { index, vertical } => {
|
||||
let pos_v = if vertical { cpt[0] } else { cpt[1] }.round();
|
||||
let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge}));
|
||||
}
|
||||
ov::Drag::Roi { start } => {
|
||||
let r = Rect::from_two_pos(map.to_screen(start), pos);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
}
|
||||
d => {
|
||||
let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
|
||||
if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
|
||||
}
|
||||
}
|
||||
},
|
||||
Gesture::Tangent { layer, mask, index, out, vertex, inv } => {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let t = [lp.x - vertex[0], lp.y - vertex[1]];
|
||||
|
|
@ -990,6 +999,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let end = map.to_comp(pos);
|
||||
create_shape(app, tool, start, end, mods.shift);
|
||||
}
|
||||
Gesture::Overlay(ov::Drag::Roi { start }) => {
|
||||
let end = map.to_comp(pos);
|
||||
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
|
||||
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
|
||||
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
|
||||
let rect = [x0.round(), y0.round(), (x1 - x0).round(), (y1 - y0).round()];
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
|
||||
}
|
||||
app.ui.viewer.roi_draw = false;
|
||||
}
|
||||
// A guide dragged off the image (back to the rulers) is removed.
|
||||
Gesture::Overlay(ov::Drag::Guide { index, .. }) if !area.contains(pos) || !comp_rect.expand(40.0).contains(pos) => {
|
||||
let _ = app.session.execute("view.removeGuide", json!({"index": index}));
|
||||
}
|
||||
Gesture::Marquee { start } if vertex_hits.iter().any(|h| h.tangent.is_none() && Rect::from_two_pos(start, pos).contains(h.pos)) => {
|
||||
// Marquee over path vertices selects them.
|
||||
let r = Rect::from_two_pos(start, pos);
|
||||
let v: Vec<serde_json::Value> = vertex_hits
|
||||
.iter()
|
||||
.filter(|h| h.tangent.is_none() && r.contains(h.pos))
|
||||
.map(|h| json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}))
|
||||
.collect();
|
||||
let _ = app.session.execute("mask.selectVertices", json!({"vertices": v, "add": mods.shift}));
|
||||
}
|
||||
Gesture::Marquee { start } => {
|
||||
let a = map.to_comp(start);
|
||||
let b = map.to_comp(pos);
|
||||
|
|
@ -1014,6 +1047,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
});
|
||||
}
|
||||
if resp.clicked()
|
||||
&& !text_owns
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
|
|
@ -1026,13 +1060,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.ui.viewer.zoom = Some(nz);
|
||||
app.ui.viewer.pan = [pan.x, pan.y];
|
||||
}
|
||||
Tool::Type | Tool::TypeVertical => {
|
||||
let r = app.session.execute("layer.newText", json!({"text": "Text", "size": 96, "position": [cpt[0], cpt[1]], "justify": "left"}));
|
||||
if let Ok(v) = r {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string())));
|
||||
Tool::Type | Tool::TypeVertical => {}
|
||||
Tool::Pen | Tool::MaskFeather => {}
|
||||
Tool::PenAdd => {
|
||||
if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) {
|
||||
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt}));
|
||||
}
|
||||
}
|
||||
Tool::Pen => {}
|
||||
Tool::PenDelete => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]}));
|
||||
}
|
||||
}
|
||||
Tool::PenConvert => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
|
||||
}
|
||||
}
|
||||
Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
|
||||
}
|
||||
}
|
||||
Tool::Selection if ft.is_some() && ft.as_ref().and_then(|f| ov::begin_ft_drag(f, pos, &map)).is_none() => {
|
||||
// Clicking away from the free-transform box ends it.
|
||||
ov::end_free_transform(&ctx);
|
||||
}
|
||||
t if t.puppet_kind().is_some() => {
|
||||
if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
|
||||
&& let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
|
||||
|
|
@ -1059,22 +1112,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& app.ui.tool != Tool::Pen
|
||||
&& !text_owns
|
||||
&& app.ui.tool == Tool::Selection
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1)))
|
||||
{
|
||||
// Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free
|
||||
// Transform Points).
|
||||
ov::begin_free_transform(&ctx, l, m);
|
||||
} else if resp.double_clicked()
|
||||
&& !text_owns
|
||||
&& !app.ui.tool.is_pen()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
|
||||
match &l.source {
|
||||
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
|
||||
effectcraft_engine::project::LayerSource::Text => {
|
||||
let doc = effectcraft_engine::render::text::source_text(&ectx, l).map(|d| d.text).unwrap_or_default();
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (l.id.0, doc)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
|
||||
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
|
||||
{
|
||||
app.session.open_comp(*item);
|
||||
}
|
||||
}
|
||||
text_edit_overlay(app, ui, &map);
|
||||
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
|
||||
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
|
||||
|
|
@ -1085,7 +1142,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.text(r.center(), Align2::CENTER_CENTER, "Caching frames…", Tokens::ui(11.5), t.cache_green);
|
||||
}
|
||||
|
||||
bottom_bar(app, ui, bar, zoom, fit, time, &comp);
|
||||
vt::draw_snap(&ctx, &painter, &map, &ectx);
|
||||
vt::rulers(app, ui, &map, outer, area);
|
||||
vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp);
|
||||
}
|
||||
|
||||
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
|
||||
|
|
@ -1181,49 +1240,94 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
|
|||
}
|
||||
}
|
||||
|
||||
/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path.
|
||||
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) {
|
||||
/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it),
|
||||
/// add a vertex on a visible path's segment, or start a new path: a mask on the selected
|
||||
/// footage/solid layer, a shape path (Shape n group with Fill and Stroke) on the selected shape
|
||||
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
|
||||
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) {
|
||||
use effectcraft_engine::project::LayerSource;
|
||||
let gid = egui::Id::new("viewer-gesture");
|
||||
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
|
||||
let target = current.map(|c| c.0).or_else(|| {
|
||||
app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked))
|
||||
});
|
||||
let Some(lid) = target else {
|
||||
app.ui.status = "Select a layer to draw a mask with the Pen tool".into();
|
||||
return;
|
||||
};
|
||||
let Some(layer) = ectx.comp.layer(lid) else { return };
|
||||
let l2c = l2c(ectx, layer).0;
|
||||
let Some(inv) = l2c.inverse() else { return };
|
||||
let c = map.to_comp(pos);
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid);
|
||||
if let (true, Some((_, mask))) = (still_there, current) {
|
||||
let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count();
|
||||
let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none());
|
||||
if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) {
|
||||
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true}));
|
||||
let c0 = map.to_comp(pos);
|
||||
let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods);
|
||||
let c = [c0[0] + corr[0], c0[1] + corr[1]];
|
||||
if let Some((lid, uid)) = current
|
||||
&& let Some(p) = ov::path_of(ectx, lid, uid)
|
||||
&& let Some(inv) = p.m.inverse()
|
||||
{
|
||||
let n = p.sp.vertices.len();
|
||||
let first = p.sp.vertices.first().map(|v| {
|
||||
let q = p.m.apply(gv2(v[0], v[1]));
|
||||
map.to_screen([q.x, q.y])
|
||||
});
|
||||
if n >= 2 && first.is_some_and(|f| f.distance(pos) < 8.0) {
|
||||
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": uid, "closed": true}));
|
||||
ui.data_mut(|d| {
|
||||
d.remove::<(LayerId, u64)>(pen_id());
|
||||
d.remove::<Gesture>(gid);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) {
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) {
|
||||
let index = v.as_u64().unwrap_or(0) as usize;
|
||||
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos }));
|
||||
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index, vertex: lp, inv, press: pos }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]}))
|
||||
&& let Some(mask) = v["mask"].as_u64()
|
||||
{
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pen_id(), (lid, mask));
|
||||
d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos });
|
||||
});
|
||||
// On a visible path's segment the Pen adds a vertex there.
|
||||
if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) {
|
||||
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t}));
|
||||
return;
|
||||
}
|
||||
let target = app
|
||||
.session
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape))));
|
||||
let layer = target.and_then(|id| ectx.comp.layer(id));
|
||||
let (lid, uid, inv) = match layer {
|
||||
Some(l) if !matches!(l.source, LayerSource::Shape) => {
|
||||
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return };
|
||||
let Some(mask) = v["mask"].as_u64() else { return };
|
||||
(l.id, mask, inv)
|
||||
}
|
||||
Some(l) => {
|
||||
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let fill = app.ui.fill_color;
|
||||
let stroke = app.ui.stroke_color;
|
||||
let r = app.session.execute(
|
||||
"shape.newPath",
|
||||
json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||||
);
|
||||
let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return };
|
||||
(l.id, path, inv)
|
||||
}
|
||||
None => {
|
||||
// A new shape layer at the comp centre (unrotated: comp → layer is a translation).
|
||||
let fill = app.ui.fill_color;
|
||||
let stroke = app.ui.stroke_color;
|
||||
let r = app.session.execute(
|
||||
"shape.newPath",
|
||||
json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||||
);
|
||||
let Some(v) = r.ok() else { return };
|
||||
let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return };
|
||||
let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0));
|
||||
(LayerId(l), path, inv)
|
||||
}
|
||||
};
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pen_id(), (lid, uid));
|
||||
d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index: 0, vertex: [lp.x, lp.y], inv, press: pos });
|
||||
});
|
||||
}
|
||||
|
||||
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
|
||||
|
|
@ -1299,170 +1403,6 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
|
|||
);
|
||||
}
|
||||
|
||||
/// Inline text editing for a text layer (double-click or Type tool click).
|
||||
fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap) {
|
||||
let id = egui::Id::new("viewer-text-edit");
|
||||
let Some((lid, mut text)) = ui.data(|d| d.get_temp::<(u64, String)>(id)) else { return };
|
||||
let comp = app.session.active_comp().cloned();
|
||||
let Some(comp) = comp else { return };
|
||||
let Some(layer) = comp.layer(LayerId(lid)).cloned() else {
|
||||
ui.data_mut(|d| d.remove::<(u64, String)>(id));
|
||||
return;
|
||||
};
|
||||
let pos = layer.transform().and_then(|tr| tr.get("position")).map(|p| p.value_at(layer.layer_time(app.session.time())).as_vec2()).unwrap_or([0.0, 0.0]);
|
||||
let at = map.to_screen(pos) + vec2(-20.0, 10.0);
|
||||
let mut done = false;
|
||||
egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(at).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Edit text — Enter to commit, Esc to cancel").small());
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_width(320.0).desired_rows(2).font(Tokens::ui(16.0)));
|
||||
r.request_focus();
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift) {
|
||||
let _ = app.session.execute("layer.setText", json!({"layer": lid, "text": text.trim_end_matches('\n')}));
|
||||
done = true;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if done {
|
||||
ui.data_mut(|d| d.remove::<(u64, String)>(id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(id, (lid, text)));
|
||||
}
|
||||
}
|
||||
|
||||
fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &effectcraft_engine::project::Comp) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(bar, 0.0, t.panel_bg);
|
||||
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
|
||||
let cy = bar.center().y;
|
||||
let mut x = bar.min.x + 8.0;
|
||||
// Magnification.
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mag = if app.ui.viewer.zoom.is_none() { format!("Fit ({:.0}%)", fit * ppp * 100.0) } else { format!("{:.1}%", zoom * ppp * 100.0).replace(".0%", "%") };
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(92.0, 20.0));
|
||||
if widgets::dropdown(ui, r, &mag, &t, egui::Id::new("vw-mag")).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("vw-mag-pop"));
|
||||
}
|
||||
app.auto.add("viewer.magnification", r, "Magnification");
|
||||
let opts: Vec<String> = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-mag-pop"), r.left_top() - vec2(0.0, 380.0), &opts, None) {
|
||||
let o = &opts[i];
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
app.ui.viewer.zoom = match o.as_str() {
|
||||
"Fit" => None,
|
||||
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
|
||||
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
|
||||
};
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
// Toggles.
|
||||
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> bool {
|
||||
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(id)).on_hover_text(tip);
|
||||
auto.add(&format!("viewer.{id}"), r, tip);
|
||||
*x += 24.0;
|
||||
resp.clicked()
|
||||
};
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Grid, app.ui.viewer.grid || app.ui.viewer.safe_margins, "grid", "Choose grid and guide options") {
|
||||
if app.ui.viewer.safe_margins {
|
||||
app.ui.viewer.safe_margins = false;
|
||||
app.ui.viewer.grid = !app.ui.viewer.grid;
|
||||
} else {
|
||||
app.ui.viewer.safe_margins = true;
|
||||
}
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility") {
|
||||
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
|
||||
}
|
||||
// Timecode.
|
||||
let tc = crate::panels::timecode(&app.session, comp, time);
|
||||
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
|
||||
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
|
||||
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
|
||||
app.auto.add("viewer.timecode", tr, "Current time");
|
||||
x = tr.max.x + 6.0;
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot") {
|
||||
app.ui.status = "Snapshot taken".into();
|
||||
}
|
||||
let _ = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, false, "showSnapshot", "Show Snapshot");
|
||||
let _ = tog(ui, &mut app.auto, &mut x, Icon::Channel, app.ui.viewer.channel != "RGB", "channel", "Show Channel and Color Management Settings");
|
||||
// Resolution.
|
||||
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(78.0, 20.0));
|
||||
let rl = if app.ui.viewer.res == Resolution::Auto {
|
||||
let s = app.viewer_scale(zoom, ppp);
|
||||
format!(
|
||||
"Auto ({})",
|
||||
if s >= 1.0 {
|
||||
"Full"
|
||||
} else if s >= 0.5 {
|
||||
"Half"
|
||||
} else if s >= 0.33 {
|
||||
"Third"
|
||||
} else {
|
||||
"Quarter"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
app.ui.viewer.res.label().to_string()
|
||||
};
|
||||
if widgets::dropdown(ui, r, &rl, &t, egui::Id::new("vw-res")).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("vw-res-pop"));
|
||||
}
|
||||
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
|
||||
let ro: Vec<String> = Resolution::ALL.iter().map(|r| r.label().to_string()).collect();
|
||||
if let Some(i) =
|
||||
widgets::popup_menu(ui, egui::Id::new("vw-res-pop"), r.left_top() - vec2(0.0, 140.0), &ro, Resolution::ALL.iter().position(|x| *x == app.ui.viewer.res))
|
||||
{
|
||||
app.ui.viewer.res = Resolution::ALL[i];
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
let has_roi = app.session.state.region_of_interest.is_some();
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi, "roi", "Region of Interest") {
|
||||
// Toggle a centred region of interest (Composition ▸ Crop Comp to Region of Interest).
|
||||
let rect = if has_roi {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!([comp.width as f64 / 4.0, comp.height as f64 / 4.0, comp.width as f64 / 2.0, comp.height as f64 / 2.0])
|
||||
};
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") {
|
||||
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Sparkle, app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews") {
|
||||
app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview;
|
||||
}
|
||||
// 3D renderer + view.
|
||||
if comp.has_3d() {
|
||||
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0));
|
||||
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
|
||||
app.auto.add("viewer.renderer3d", r, "3D Renderer");
|
||||
x = r.max.x + 4.0;
|
||||
let cid = app.session.active_comp_id();
|
||||
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
|
||||
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("vw-cam-pop"));
|
||||
}
|
||||
app.auto.add("viewer.view3d", r, "3D View");
|
||||
let opts: Vec<String> = View3D::ALL.iter().map(|v| v.label().to_string()).collect();
|
||||
let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0);
|
||||
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) {
|
||||
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
|
||||
}
|
||||
}
|
||||
// Right: render time.
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
|
||||
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,528 @@
|
|||
//! Composition viewer overlays and their drags: mask and shape paths (vertices, tangents,
|
||||
//! segments for Add Vertex), position motion paths with keyframe dots and spatial Bezier
|
||||
//! handles, the path free-transform box and the Mask Feather tool.
|
||||
//!
|
||||
//! Every edit is an engine command (`mask.*`, `path.freeTransform`, `keys.set`,
|
||||
//! `keys.setSpatialTangents`, `prop.set`).
|
||||
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::keyframe::ShapePath;
|
||||
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_engine::{KeyRef, VertexRef};
|
||||
use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
/// A vertex or tangent handle drawn this frame (masks and shape paths alike; `mask` is the path
|
||||
/// group's uid).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct VertexHit {
|
||||
pub layer: LayerId,
|
||||
pub mask: u64,
|
||||
pub index: usize,
|
||||
pub pos: Pos2,
|
||||
/// None = the vertex itself, Some(out?) = a tangent handle.
|
||||
pub tangent: Option<bool>,
|
||||
/// Path-space vertex position and path → comp matrix.
|
||||
pub vertex: [f64; 2],
|
||||
pub l2c: Mat3,
|
||||
}
|
||||
|
||||
/// A path drawn this frame (for segment hits and the free-transform box).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PathInfo {
|
||||
pub layer: LayerId,
|
||||
pub uid: u64,
|
||||
/// Path space → comp.
|
||||
pub m: Mat3,
|
||||
pub sp: ShapePath,
|
||||
pub is_mask: bool,
|
||||
}
|
||||
|
||||
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
|
||||
let u = 1.0 - t;
|
||||
let f = |k: usize| u * u * u * p0[k] + 3.0 * u * u * t * p1[k] + 3.0 * u * t * t * p2[k] + t * t * t * p3[k];
|
||||
[f(0), f(1)]
|
||||
}
|
||||
|
||||
/// Control points of segment `i` (vertex i → next) in path space.
|
||||
pub(crate) fn segment(sp: &ShapePath, i: usize) -> Option<[[f64; 2]; 4]> {
|
||||
let n = sp.vertices.len();
|
||||
let j = if i + 1 < n {
|
||||
i + 1
|
||||
} else if sp.closed && n > 1 {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let (a, d) = (sp.vertices[i], sp.vertices[j]);
|
||||
let to = sp.out_tangents.get(i).copied().unwrap_or_default();
|
||||
let ti = sp.in_tangents.get(j).copied().unwrap_or_default();
|
||||
Some([a, [a[0] + to[0], a[1] + to[1]], [d[0] + ti[0], d[1] + ti[1]], d])
|
||||
}
|
||||
|
||||
/// The path segment under a screen point: (layer, path uid, segment, t).
|
||||
pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64, usize, f64)> {
|
||||
let mut best: Option<(f32, (LayerId, u64, usize, f64))> = None;
|
||||
for p in paths {
|
||||
for i in 0..p.sp.vertices.len() {
|
||||
let Some(c) = segment(&p.sp, i) else { continue };
|
||||
for k in 1..48 {
|
||||
let t = k as f64 / 48.0;
|
||||
let q = cubic(c[0], c[1], c[2], c[3], t);
|
||||
let cq = p.m.apply(gv2(q[0], q[1]));
|
||||
let d = map.to_screen([cq.x, cq.y]).distance(pos);
|
||||
if d < tol && best.as_ref().is_none_or(|b| d < b.0) {
|
||||
best = Some((d, (p.layer, p.uid, i, t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// A mask or shape path of a layer by group uid, evaluated at the context time.
|
||||
pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathInfo> {
|
||||
let l = ectx.comp.layer(layer)?;
|
||||
let g = l.props.find_group(uid)?;
|
||||
let sp = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned())?;
|
||||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
|
||||
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
|
||||
Some(PathInfo { layer, uid, m, sp, is_mask })
|
||||
}
|
||||
|
||||
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
|
||||
let k = effectcraft_engine::render::kurbo_path(sp);
|
||||
let mut pts: Vec<Pos2> = vec![];
|
||||
kurbo::flatten(k.iter(), 0.5, |el| match el {
|
||||
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
|
||||
let c = m.apply(gv2(q.x, q.y));
|
||||
pts.push(map.to_screen([c.x, c.y]));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if sp.closed && !pts.is_empty() {
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Draw the masks and (on shape layers) shape paths of a selected layer with their vertices;
|
||||
/// selected vertices show their tangent handles. Collects vertex hits and paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn draw_paths(
|
||||
app: &mut EffectcraftApp,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
l: &Layer,
|
||||
label_col: Color32,
|
||||
sel: &[VertexRef],
|
||||
pen: Option<(LayerId, u64)>,
|
||||
hover: Option<Pos2>,
|
||||
hits: &mut Vec<VertexHit>,
|
||||
paths: &mut Vec<PathInfo>,
|
||||
) {
|
||||
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
|
||||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let mut list: Vec<(u64, String, ShapePath, Mat3, Color32, bool)> = vec![];
|
||||
if let Some(masks) = l.masks() {
|
||||
for g in masks.groups() {
|
||||
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
|
||||
if locked && app.session.state.hide_locked_masks {
|
||||
continue;
|
||||
}
|
||||
let Some(sp) = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned()) else { continue };
|
||||
list.push((g.uid, g.name.clone(), sp, m, Color32::from_rgb(color[0], color[1], color[2]), true));
|
||||
}
|
||||
}
|
||||
for (uid, sp) in effectcraft_engine::viewer::shape_paths(ectx, l) {
|
||||
let im = effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY);
|
||||
let name = l.props.find_group(uid).map(|g| g.name.clone()).unwrap_or_default();
|
||||
list.push((uid, name, sp, m * im, label_col, false));
|
||||
}
|
||||
for (uid, name, sp, pm, mc, is_mask) in list {
|
||||
painter.add(egui::Shape::line(flatten(&sp, &pm, map), Stroke::new(1.0, mc)));
|
||||
let scr = |p: [f64; 2]| {
|
||||
let c = pm.apply(gv2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
for (i, v) in sp.vertices.iter().enumerate() {
|
||||
let s = scr(*v);
|
||||
let selected = sel.iter().any(|x| x.layer == l.id && x.mask == uid && x.index == i);
|
||||
if selected {
|
||||
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
|
||||
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
|
||||
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, mc));
|
||||
painter.circle_filled(h, 3.0, mc);
|
||||
hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: pm });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
let kind = if is_mask { "mask" } else { "shapePath" };
|
||||
app.auto.add(&format!("viewer.{kind}.{uid}.vertex.{i}"), Rect::from_center_size(s, vec2(8.0, 8.0)), &name);
|
||||
hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: s, tangent: None, vertex: *v, l2c: pm });
|
||||
}
|
||||
// Pen rubber band from the last vertex to the pointer.
|
||||
if pen == Some((l.id, uid))
|
||||
&& let (Some(last), Some(hp)) = (sp.vertices.last(), hover)
|
||||
{
|
||||
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
|
||||
}
|
||||
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion paths
|
||||
|
||||
/// A keyframe dot or spatial tangent handle on a motion path.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct KeyHit {
|
||||
pub layer: LayerId,
|
||||
pub prop: u64,
|
||||
/// Layer time of the key.
|
||||
pub time: Tick,
|
||||
pub pos: Pos2,
|
||||
/// None = the key, Some(out?) = a tangent handle.
|
||||
pub tangent: Option<bool>,
|
||||
pub value: [f64; 3],
|
||||
/// Parent space → comp.
|
||||
pub p2c: Mat3,
|
||||
}
|
||||
|
||||
/// Parent space → comp matrix of a 2D layer at the context time.
|
||||
pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 {
|
||||
match l.parent.and_then(|p| ectx.comp.layer(p)) {
|
||||
Some(p) => {
|
||||
let w = ectx.world_matrix(p).0;
|
||||
Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]])
|
||||
}
|
||||
None => Mat3::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe
|
||||
/// squares (filled when selected) and spatial Bezier tangent handles. Works through parents.
|
||||
pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec<KeyHit>) {
|
||||
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
|
||||
if pos.keys.len() < 2 || l.is_3d() {
|
||||
return;
|
||||
}
|
||||
let p2c = parent_to_comp(ectx, l);
|
||||
let to_scr = |v: [f64; 3]| {
|
||||
let c = p2c.apply(gv2(v[0], v[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time);
|
||||
let fd = ectx.comp.frame_duration();
|
||||
let mut pts = vec![];
|
||||
let mut lt = lt0;
|
||||
while lt <= lt1 && pts.len() < 20_000 {
|
||||
pts.push(to_scr(pos.value_at(lt).as_vec3()));
|
||||
lt += fd;
|
||||
}
|
||||
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
|
||||
let faint = col.gamma_multiply(0.8);
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
|
||||
for q in &pts {
|
||||
painter.circle_filled(*q, 1.2, faint);
|
||||
}
|
||||
let sel = &app.session.state.selected_keys;
|
||||
for (i, k) in pos.keys.iter().enumerate() {
|
||||
let v = k.value.as_vec3();
|
||||
let s = to_scr(v);
|
||||
let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i);
|
||||
for (out, tg) in [(false, tin), (true, tout)] {
|
||||
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
|
||||
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
|
||||
continue;
|
||||
}
|
||||
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, col));
|
||||
painter.circle_filled(h, 3.0, col);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
}
|
||||
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
|
||||
if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) {
|
||||
painter.rect_filled(r, 0.0, col);
|
||||
} else {
|
||||
painter.rect_filled(r, 0.0, Color32::from_black_alpha(140));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- free transform
|
||||
|
||||
fn ft_id() -> egui::Id {
|
||||
egui::Id::new("viewer-free-transform")
|
||||
}
|
||||
|
||||
/// Enter Free Transform on a path (double-click a path with the Selection tool).
|
||||
pub(crate) fn begin_free_transform(ctx: &egui::Context, layer: LayerId, uid: u64) {
|
||||
ctx.data_mut(|d| d.insert_temp(ft_id(), (layer, uid)));
|
||||
}
|
||||
|
||||
pub(crate) fn end_free_transform(ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| d.remove::<(LayerId, u64)>(ft_id()));
|
||||
}
|
||||
|
||||
pub(crate) fn free_transform_target(ctx: &egui::Context) -> Option<(LayerId, u64)> {
|
||||
ctx.data(|d| d.get_temp(ft_id()))
|
||||
}
|
||||
|
||||
/// The free-transform box: path-space bounds of the transformed vertices, its handles on screen
|
||||
/// (corners 0–3, edge midpoints 4–7) and the path → comp matrix.
|
||||
pub(crate) struct FtBox {
|
||||
pub layer: LayerId,
|
||||
pub uid: u64,
|
||||
pub b: [f64; 4],
|
||||
pub m: Mat3,
|
||||
pub handles: [Pos2; 8],
|
||||
pub quad: [Pos2; 4],
|
||||
}
|
||||
|
||||
impl FtBox {
|
||||
/// Path-space position of handle `i`.
|
||||
pub fn handle_local(&self, i: usize) -> [f64; 2] {
|
||||
let [x0, y0, x1, y1] = self.b;
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [cx, y0], [x1, cy], [cx, y1], [x0, cy]][i]
|
||||
}
|
||||
pub fn center(&self) -> [f64; 2] {
|
||||
[(self.b[0] + self.b[2]) / 2.0, (self.b[1] + self.b[3]) / 2.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the free-transform box of the target path (if any) and return it for hit tests.
|
||||
pub(crate) fn draw_free_transform(
|
||||
app: &mut EffectcraftApp,
|
||||
ctx: &egui::Context,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
paths: &[PathInfo],
|
||||
) -> Option<FtBox> {
|
||||
let (layer, uid) = free_transform_target(ctx)?;
|
||||
let Some(p) = paths.iter().find(|p| p.layer == layer && p.uid == uid) else {
|
||||
end_free_transform(ctx);
|
||||
return None;
|
||||
};
|
||||
let sel: Vec<usize> = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect();
|
||||
let idx: Vec<usize> = if sel.len() >= 2 { sel } else { (0..p.sp.vertices.len()).collect() };
|
||||
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
|
||||
for &i in &idx {
|
||||
let Some(v) = p.sp.vertices.get(i) else { continue };
|
||||
lo = [lo[0].min(v[0]), lo[1].min(v[1])];
|
||||
hi = [hi[0].max(v[0]), hi[1].max(v[1])];
|
||||
}
|
||||
if lo[0] > hi[0] {
|
||||
return None;
|
||||
}
|
||||
let b = [lo[0], lo[1], hi[0].max(lo[0] + 1e-3), hi[1].max(lo[1] + 1e-3)];
|
||||
let mut f = FtBox { layer, uid, b, m: p.m, handles: [Pos2::ZERO; 8], quad: [Pos2::ZERO; 4] };
|
||||
for i in 0..8 {
|
||||
let q = f.handle_local(i);
|
||||
let c = p.m.apply(gv2(q[0], q[1]));
|
||||
f.handles[i] = map.to_screen([c.x, c.y]);
|
||||
}
|
||||
f.quad = [f.handles[0], f.handles[1], f.handles[2], f.handles[3]];
|
||||
let col = app.tokens.accent;
|
||||
painter.add(egui::Shape::closed_line(f.quad.to_vec(), Stroke::new(1.0, col)));
|
||||
for (i, h) in f.handles.iter().enumerate() {
|
||||
let r = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Outside);
|
||||
app.auto.add(&format!("viewer.freeTransform.handle.{i}"), r, "Free transform handle");
|
||||
}
|
||||
let c = f.center();
|
||||
let cc = p.m.apply(gv2(c[0], c[1]));
|
||||
let cs = map.to_screen([cc.x, cc.y]);
|
||||
painter.circle_stroke(cs, 4.0, Stroke::new(1.0, col));
|
||||
Some(f)
|
||||
}
|
||||
|
||||
fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
|
||||
let mut sign = 0.0f32;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
let c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||||
if c.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = c.signum();
|
||||
} else if c.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drags
|
||||
|
||||
/// Overlay drags owned by this module.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Drag {
|
||||
/// A motion-path keyframe: its value moves with the pointer (parent space).
|
||||
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
|
||||
/// A spatial tangent handle of a motion-path key.
|
||||
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
|
||||
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
|
||||
Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] },
|
||||
/// Mask Feather tool: drag away from the path to feather the mask.
|
||||
Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 },
|
||||
/// A guide dragged in the viewer.
|
||||
Guide { index: usize, vertical: bool },
|
||||
/// Drawing the region of interest.
|
||||
Roi { start: [f64; 2] },
|
||||
}
|
||||
|
||||
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
|
||||
pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: Pos2, cpt: [f64; 2], shift: bool) -> Option<Drag> {
|
||||
let h = hits
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|h| h.tangent.is_some())
|
||||
.find(|h| h.pos.distance(press) < 6.0)
|
||||
.or_else(|| hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(press) < 6.0))?;
|
||||
let inv = h.p2c.inverse().unwrap_or(Mat3::IDENTITY);
|
||||
match h.tangent {
|
||||
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
|
||||
None => {
|
||||
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
|
||||
if !app.session.state.selected_keys.contains(&k) || shift {
|
||||
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
|
||||
}
|
||||
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a free-transform drag on the box: a handle scales about the opposite one (corners both
|
||||
/// axes, edges one), inside moves, just outside a corner rotates about the centre.
|
||||
pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<Drag> {
|
||||
let inv = f.m.inverse()?;
|
||||
let c = map.to_comp(press);
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
if let Some(i) = (0..8).find(|i| f.handles[*i].distance(press) < 7.0) {
|
||||
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
|
||||
let axes = match i {
|
||||
4 | 6 => [false, true],
|
||||
5 | 7 => [true, false],
|
||||
_ => [true, true],
|
||||
};
|
||||
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 1, anchor: f.handle_local(opp), last: lp, inv, axes });
|
||||
}
|
||||
if point_in_quad(press, &f.quad) {
|
||||
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 0, anchor: f.center(), last: lp, inv, axes: [true, true] });
|
||||
}
|
||||
if f.quad.iter().any(|q| q.distance(press) < 24.0) {
|
||||
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 2, anchor: f.center(), last: lp, inv, axes: [true, true] });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mask Feather tool: start on a mask path.
|
||||
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
|
||||
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), map, press, 10.0)?;
|
||||
let l = ectx.comp.layer(layer)?;
|
||||
let pr = l.masks()?.find_group(uid)?.get("feather")?;
|
||||
let _ = app;
|
||||
Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press })
|
||||
}
|
||||
|
||||
/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp
|
||||
/// point for snapping. Returns the replacement drag state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn update(
|
||||
app: &mut EffectcraftApp,
|
||||
d: &Drag,
|
||||
cpt: [f64; 2],
|
||||
pos: Pos2,
|
||||
mods: egui::Modifiers,
|
||||
merge: &str,
|
||||
zoom: f32,
|
||||
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
|
||||
) -> Option<Drag> {
|
||||
match d.clone() {
|
||||
Drag::Key { layer, prop, time, start, press, inv } => {
|
||||
// Snap the key's on-screen position like a layer drag.
|
||||
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
|
||||
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
|
||||
let s = snap(app, target, layer);
|
||||
target = [target[0] + s[0], target[1] + s[1]];
|
||||
let p = inv.apply(gv2(target[0], target[1]));
|
||||
let v = json!([p.x, p.y, start[2]]);
|
||||
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
None
|
||||
}
|
||||
Drag::KeyTangent { layer, prop, time, out, key, inv } => {
|
||||
let p = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let tg = json!([p.x - key[0], p.y - key[1], 0.0]);
|
||||
let mut q = json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "break": mods.alt, "merge": merge});
|
||||
q[if out { "out" } else { "in" }] = tg;
|
||||
let _ = app.session.execute("keys.setSpatialTangents", q);
|
||||
None
|
||||
}
|
||||
Drag::Ft { layer, path, mode, anchor, last, inv, axes } => {
|
||||
let p = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let cur = [p.x, p.y];
|
||||
let mut q = json!({"layer": layer.0, "mask": path, "anchor": anchor, "merge": merge});
|
||||
match mode {
|
||||
0 => q["offset"] = json!([cur[0] - last[0], cur[1] - last[1]]),
|
||||
1 => {
|
||||
let f = |k: usize| {
|
||||
let a = last[k] - anchor[k];
|
||||
if axes[k] && a.abs() > 1e-6 { ((cur[k] - anchor[k]) / a).clamp(-50.0, 50.0) } else { 1.0 }
|
||||
};
|
||||
let (mut fx, mut fy) = (f(0), f(1));
|
||||
if mods.shift && axes[0] && axes[1] {
|
||||
let k = (fx + fy) / 2.0;
|
||||
(fx, fy) = (k, k);
|
||||
}
|
||||
if fx.abs() < 1e-3 || fy.abs() < 1e-3 {
|
||||
return None;
|
||||
}
|
||||
q["scale"] = json!([fx * 100.0, fy * 100.0]);
|
||||
}
|
||||
_ => {
|
||||
let a0 = (last[1] - anchor[1]).atan2(last[0] - anchor[0]);
|
||||
let a1 = (cur[1] - anchor[1]).atan2(cur[0] - anchor[0]);
|
||||
q["rotation"] = json!((a1 - a0).to_degrees());
|
||||
}
|
||||
}
|
||||
if cur != last {
|
||||
let _ = app.session.execute("path.freeTransform", q);
|
||||
}
|
||||
Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes })
|
||||
}
|
||||
Drag::Feather { layer, prop, start, press } => {
|
||||
let amt = (pos.distance(press) / zoom.max(1e-3)) as f64;
|
||||
let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge}));
|
||||
None
|
||||
}
|
||||
Drag::Guide { index, .. } => {
|
||||
let _ = index;
|
||||
None
|
||||
}
|
||||
Drag::Roi { .. } => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
//! On-canvas text editing in the Composition viewer (Type tool, double-click a text layer).
|
||||
//!
|
||||
//! - **Type tool**: click empty space for point text, drag for paragraph (box) text, click a text
|
||||
//! layer to put the caret there. Cmd+T toggles horizontal / vertical type.
|
||||
//! - **Editing**: caret at glyph boundaries, mouse click / drag selection, double-click selects a
|
||||
//! word and triple-click a line; arrows, Home / End and word / paragraph jumps (Cmd), Shift
|
||||
//! extends; typing, Backspace / Delete (Cmd or Alt: by word), Enter (new paragraph), Tab, Cmd+A,
|
||||
//! copy / cut / paste, IME composition; Alt+←/→ kerning (caret) or tracking (selection) ±20
|
||||
//! (Cmd: ±100); Cmd+Shift+< / > font size ±2; Cmd+Z / Cmd+Shift+Z undo / redo. Escape or
|
||||
//! Cmd+Enter commits. Paragraph text shows its box with handles that resize it.
|
||||
//! - Selection highlight, caret and box are drawn in comp space through the layer's transform
|
||||
//! (and parenting).
|
||||
//!
|
||||
//! Every change runs an engine command (`text.*`, `layer.setText`, `edit.*`), so it's undoable and
|
||||
//! agents can do the same over the control channel.
|
||||
|
||||
use effectcraft_engine::commands::text_edit::layer_doc;
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::keyframe::{Kerning, TextDoc};
|
||||
use effectcraft_engine::project::{Layer, LayerId, LayerSource};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::text::kurbo::Point;
|
||||
use effectcraft_engine::text::{TextLayout, layout_doc};
|
||||
use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::EffectcraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Pointer interaction owned by text editing (kept across frames while the button is down).
|
||||
#[derive(Clone, Debug)]
|
||||
enum Drag {
|
||||
/// Extending the selection from the press.
|
||||
Select { layer: LayerId },
|
||||
/// Type tool: a click (point text) or a drag (paragraph box) from `start` (comp space).
|
||||
Create { start: [f64; 2], vertical: bool },
|
||||
/// Resizing a paragraph box by one of its 8 handles.
|
||||
Resize { layer: LayerId, handle: usize, start_box: [f64; 4] },
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("viewer-text-drag")
|
||||
}
|
||||
fn focus_id() -> egui::Id {
|
||||
egui::Id::new("viewer-text-focus")
|
||||
}
|
||||
|
||||
/// The geometry of an edited text layer.
|
||||
struct Edited {
|
||||
layer: Layer,
|
||||
doc: TextDoc,
|
||||
lay: TextLayout,
|
||||
/// Layer → comp.
|
||||
m: Mat3,
|
||||
inv: Mat3,
|
||||
}
|
||||
|
||||
impl Edited {
|
||||
fn to_screen(&self, map: &ViewerMap, p: (f64, f64)) -> Pos2 {
|
||||
let q = self.m.apply(gv2(p.0, p.1));
|
||||
map.to_screen([q.x, q.y])
|
||||
}
|
||||
fn to_layer(&self, map: &ViewerMap, s: Pos2) -> (f64, f64) {
|
||||
let c = map.to_comp(s);
|
||||
let q = self.inv.apply(gv2(c[0], c[1]));
|
||||
(q.x, q.y)
|
||||
}
|
||||
/// The text's region in layer space: the box, or the laid-out lines.
|
||||
fn region(&self) -> [f64; 4] {
|
||||
if let Some(b) = self.doc.box_size {
|
||||
return [self.doc.box_pos[0], self.doc.box_pos[1], self.doc.box_pos[0] + b[0], self.doc.box_pos[1] + b[1]];
|
||||
}
|
||||
let b = self.lay.layout.bounds;
|
||||
let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| self.lay.to_layer * Point::new(x as f64, y as f64));
|
||||
let xs = corners.iter().map(|p| p.x);
|
||||
let ys = corners.iter().map(|p| p.y);
|
||||
let pad = 6.0;
|
||||
[
|
||||
xs.clone().fold(f64::INFINITY, f64::min) - pad,
|
||||
ys.clone().fold(f64::INFINITY, f64::min) - pad,
|
||||
xs.fold(f64::NEG_INFINITY, f64::max) + pad,
|
||||
ys.fold(f64::NEG_INFINITY, f64::max) + pad,
|
||||
]
|
||||
}
|
||||
fn contains(&self, map: &ViewerMap, s: Pos2) -> bool {
|
||||
let (x, y) = self.to_layer(map, s);
|
||||
let r = self.region();
|
||||
x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]
|
||||
}
|
||||
fn hit(&self, map: &ViewerMap, s: Pos2) -> usize {
|
||||
let (x, y) = self.to_layer(map, s);
|
||||
self.lay.hit(Point::new(x, y))
|
||||
}
|
||||
/// Box handle positions (layer space): corners and edge midpoints, clockwise from top-left.
|
||||
fn handles(&self) -> Vec<(f64, f64)> {
|
||||
let Some(b) = self.doc.box_size else { return vec![] };
|
||||
let (x0, y0) = (self.doc.box_pos[0], self.doc.box_pos[1]);
|
||||
let (x1, y1) = (x0 + b[0], y0 + b[1]);
|
||||
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
vec![(x0, y0), (mx, y0), (x1, y0), (x1, my), (x1, y1), (mx, y1), (x0, y1), (x0, my)]
|
||||
}
|
||||
}
|
||||
|
||||
fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited> {
|
||||
let layer = ectx.comp.layer(lid)?.clone();
|
||||
if !matches!(layer.source, LayerSource::Text) {
|
||||
return None;
|
||||
}
|
||||
let doc = layer_doc(&app.session, lid)?;
|
||||
let lay = layout_doc(&doc);
|
||||
let m = super::viewer::l2c(ectx, &layer).0;
|
||||
let inv = m.inverse()?;
|
||||
Some(Edited { layer, doc, lay, m, inv })
|
||||
}
|
||||
|
||||
/// The topmost text layer whose text is under screen point `s`.
|
||||
fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2) -> Option<Edited> {
|
||||
ectx.comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| matches!(l.source, LayerSource::Text) && l.is_active_at(ectx.time) && l.switches.video && !l.switches.locked)
|
||||
.filter_map(|l| edited(app, ectx, l.id))
|
||||
.find(|e| e.contains(map, s))
|
||||
}
|
||||
|
||||
fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option<Value> {
|
||||
match app.session.execute(id, p) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
app.ui.status = e.to_string();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing merge key: consecutive keystrokes fold into one undo step until the caret moves.
|
||||
fn typing_key(ui: &egui::Ui, bump: bool) -> String {
|
||||
let id = egui::Id::new("viewer-text-typing");
|
||||
let mut n: u64 = ui.data(|d| d.get_temp(id)).unwrap_or(0);
|
||||
if bump {
|
||||
n += 1;
|
||||
ui.data_mut(|d| d.insert_temp(id, n));
|
||||
}
|
||||
format!("text-typing-{n}")
|
||||
}
|
||||
|
||||
/// Viewer hook: draws the edit overlay and handles text pointer and keyboard input. Returns
|
||||
/// true when it owns this frame's pointer interaction (the viewer then skips its own gestures).
|
||||
pub fn hook(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
resp: &egui::Response,
|
||||
space_pan: bool,
|
||||
) -> bool {
|
||||
// The keyboard target: a focusable (non-clickable) widget, so egui and AccessKit know it.
|
||||
ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
|
||||
let tool = app.ui.tool;
|
||||
let type_tool = matches!(tool, Tool::Type | Tool::TypeVertical);
|
||||
// The session ends when its layer leaves the active comp.
|
||||
if let Some(e) = app.session.state.text_edit.clone()
|
||||
&& ectx.comp.layer(e.layer).is_none()
|
||||
{
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
let editing = app.session.state.text_edit.clone();
|
||||
let cur = editing.as_ref().and_then(|e| edited(app, ectx, e.layer));
|
||||
let mut owns = false;
|
||||
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
let (pressed, down, released, mods) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released(), i.modifiers));
|
||||
let press = ui.input(|i| i.pointer.press_origin());
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
let handle_hit = |e: &Edited, s: Pos2| e.handles().iter().position(|h| e.to_screen(map, *h).distance(s) < 7.0);
|
||||
|
||||
// ---- press
|
||||
if pressed
|
||||
&& !space_pan
|
||||
&& resp.hovered()
|
||||
&& !matches!(tool, Tool::Hand | Tool::Zoom)
|
||||
&& let Some(pos) = press
|
||||
{
|
||||
let mut handled = false;
|
||||
if let Some(e) = &cur {
|
||||
if let Some(h) = handle_hit(e, pos) {
|
||||
let b = e.doc.box_size.unwrap_or([0.0; 2]);
|
||||
drag = Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
|
||||
handled = true;
|
||||
} else if e.contains(map, pos) {
|
||||
let ci = e.hit(map, pos);
|
||||
let sel = editing.as_ref().expect("editing");
|
||||
let anchor = if mods.shift { sel.anchor } else { ci };
|
||||
exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
|
||||
typing_key(ui, true);
|
||||
drag = Some(Drag::Select { layer: e.layer.id });
|
||||
handled = true;
|
||||
} else {
|
||||
// Clicking elsewhere commits the edit.
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
}
|
||||
if !handled && type_tool {
|
||||
if let Some(e) = text_layer_at(app, ectx, map, pos) {
|
||||
let ci = e.hit(map, pos);
|
||||
exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
|
||||
drag = Some(Drag::Select { layer: e.layer.id });
|
||||
} else {
|
||||
drag = Some(Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical });
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
if handled {
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
owns = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- drag / release
|
||||
match drag.clone() {
|
||||
Some(Drag::Select { layer }) => {
|
||||
owns = true;
|
||||
if down
|
||||
&& let Some(p) = pointer
|
||||
&& let Some(e) = edited(app, ectx, layer)
|
||||
&& press.is_some_and(|q| q.distance(p) > 2.0)
|
||||
{
|
||||
let ci = e.hit(map, p);
|
||||
if app.session.state.text_edit.as_ref().is_some_and(|s| s.caret != ci) {
|
||||
let anchor = app.session.state.text_edit.as_ref().map_or(ci, |s| s.anchor);
|
||||
exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Drag::Create { start, vertical }) => {
|
||||
owns = true;
|
||||
let end = pointer.map(|p| map.to_comp(p)).unwrap_or(start);
|
||||
let a = map.to_screen(start);
|
||||
let b = map.to_screen(end);
|
||||
let is_box = a.distance(b) > 6.0;
|
||||
if is_box && down {
|
||||
painter.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle);
|
||||
}
|
||||
if released {
|
||||
let mut p = json!({"text": "", "edit": true, "size": 72, "fill": [1.0, 1.0, 1.0], "vertical": vertical});
|
||||
if is_box {
|
||||
let (x0, y0) = (start[0].min(end[0]), start[1].min(end[1]));
|
||||
p["box"] = json!([x0, y0, (end[0] - start[0]).abs(), (end[1] - start[1]).abs()]);
|
||||
} else {
|
||||
p["position"] = json!([start[0], start[1]]);
|
||||
p["justify"] = json!("left");
|
||||
}
|
||||
exec(app, "layer.newText", p);
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
}
|
||||
}
|
||||
Some(Drag::Resize { layer, handle, start_box }) => {
|
||||
owns = true;
|
||||
if down
|
||||
&& let (Some(p), Some(q)) = (pointer, press)
|
||||
&& let Some(e) = edited(app, ectx, layer)
|
||||
{
|
||||
let (px, py) = e.to_layer(map, p);
|
||||
let (qx, qy) = e.to_layer(map, q);
|
||||
let (dx, dy) = (px - qx, py - qy);
|
||||
let [mut x0, mut y0, w, h] = start_box;
|
||||
let (mut x1, mut y1) = (x0 + w, y0 + h);
|
||||
if matches!(handle, 0 | 6 | 7) {
|
||||
x0 += dx;
|
||||
}
|
||||
if matches!(handle, 2..=4) {
|
||||
x1 += dx;
|
||||
}
|
||||
if matches!(handle, 0..=2) {
|
||||
y0 += dy;
|
||||
}
|
||||
if matches!(handle, 4..=6) {
|
||||
y1 += dy;
|
||||
}
|
||||
let bx = [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)];
|
||||
exec(app, "layer.setText", json!({"layer": layer.0, "box": bx, "merge": format!("text-box-{}", layer.0)}));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Double / triple click: word / line; double-click a text layer to edit it.
|
||||
if resp.triple_clicked()
|
||||
&& let Some(p) = pointer
|
||||
&& let Some(e) = app.session.state.text_edit.clone().and_then(|s| edited(app, ectx, s.layer))
|
||||
&& e.contains(map, p)
|
||||
{
|
||||
let (a, b) = e.lay.line_span(e.hit(map, p));
|
||||
exec(app, "text.setSelection", json!({"anchor": a, "caret": b}));
|
||||
owns = true;
|
||||
} else if resp.double_clicked()
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let cur2 = app.session.state.text_edit.clone().and_then(|s| edited(app, ectx, s.layer));
|
||||
match cur2 {
|
||||
Some(e) if e.contains(map, p) => {
|
||||
let r = effectcraft_engine::keyframe::text_doc::word_at(&e.doc.text, e.hit(map, p));
|
||||
exec(app, "text.setSelection", json!({"anchor": r.start, "caret": r.end}));
|
||||
owns = true;
|
||||
}
|
||||
_ if !matches!(tool, Tool::Hand | Tool::Zoom) && !tool.is_pen() && !type_tool => {
|
||||
if let Some(e) = text_layer_at(app, ectx, map, p) {
|
||||
exec(app, "text.edit", json!({"layer": e.layer.id.0}));
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
owns = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if released || !down {
|
||||
ui.data_mut(|d| d.remove::<Drag>(drag_id()));
|
||||
} else if let Some(d) = drag {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
|
||||
}
|
||||
|
||||
// ---- keyboard
|
||||
if app.session.state.text_edit.is_some() {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
|
||||
});
|
||||
// A session started elsewhere (menu, agent, Properties) takes the keyboard once.
|
||||
let seen = egui::Id::new("viewer-text-seen");
|
||||
let key = app.session.state.text_edit.as_ref().map(|e| e.layer.0);
|
||||
// …and gets it back whenever nothing else holds it (after a panel click, say).
|
||||
if ui.data(|d| d.get_temp::<Option<u64>>(seen)).flatten() != key || ui.memory(|m| m.focused().is_none()) {
|
||||
ui.data_mut(|d| d.insert_temp(seen, key));
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
}
|
||||
if ui.memory(|m| m.has_focus(focus_id())) {
|
||||
keyboard(app, ui);
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp::<Option<u64>>(egui::Id::new("viewer-text-seen"), None));
|
||||
if ui.memory(|m| m.has_focus(focus_id())) {
|
||||
ui.memory_mut(|m| m.surrender_focus(focus_id()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- overlay
|
||||
if let Some(s) = app.session.state.text_edit.clone()
|
||||
&& let Some(e) = edited(app, ectx, s.layer)
|
||||
{
|
||||
draw(app, ui, painter, map, &e, s.range(), s.caret);
|
||||
}
|
||||
owns
|
||||
}
|
||||
|
||||
fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
let events = ui.input(|i| i.events.clone());
|
||||
let ctx = ui.ctx().clone();
|
||||
for ev in events {
|
||||
let Some(sel) = app.session.state.text_edit.clone() else { return };
|
||||
match ev {
|
||||
egui::Event::Text(t) if !t.chars().any(char::is_control) => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": t, "merge": k}));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) if !t.is_empty() => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": t, "merge": k}));
|
||||
ui.data_mut(|d| d.remove::<String>(egui::Id::new("viewer-text-preedit")));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-preedit"), text));
|
||||
}
|
||||
egui::Event::Copy => {
|
||||
if let Some(v) = exec(app, "edit.copy", json!({}))
|
||||
&& let Some(t) = v["text"].as_str()
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
}
|
||||
egui::Event::Cut => {
|
||||
if let Some(v) = exec(app, "edit.cut", json!({}))
|
||||
&& let Some(t) = v["text"].as_str()
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
typing_key(ui, true);
|
||||
}
|
||||
egui::Event::Paste(t) => {
|
||||
exec(app, "edit.paste", json!({"text": t}));
|
||||
typing_key(ui, true);
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
|
||||
use egui::Key;
|
||||
let cmd = m.command;
|
||||
let mv = |app: &mut EffectcraftApp, to: &str| {
|
||||
exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
|
||||
};
|
||||
match key {
|
||||
Key::ArrowLeft | Key::ArrowRight if m.alt => {
|
||||
// Kerning at the caret, tracking over a selection.
|
||||
let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
|
||||
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
|
||||
if sel.is_empty() {
|
||||
if sel.caret < doc.char_len() {
|
||||
let cur = match doc.style_at(sel.caret).kerning {
|
||||
Kerning::Manual(v) => v,
|
||||
_ => 0.0,
|
||||
};
|
||||
exec(
|
||||
app,
|
||||
"layer.setText",
|
||||
json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let r = sel.range();
|
||||
let cur = doc.style_at(r.start).tracking;
|
||||
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
|
||||
}
|
||||
}
|
||||
Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
|
||||
Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
|
||||
Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
|
||||
Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
|
||||
Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
|
||||
Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
|
||||
Key::Backspace => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k}));
|
||||
}
|
||||
Key::Delete => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k}));
|
||||
}
|
||||
Key::Enter if cmd => {
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
Key::Enter => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": "\n", "merge": k}));
|
||||
}
|
||||
Key::Tab => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": "\t", "merge": k}));
|
||||
}
|
||||
Key::Escape => {
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
Key::A if cmd => {
|
||||
exec(app, "text.setSelection", json!({"select": "all"}));
|
||||
}
|
||||
Key::Z if cmd => {
|
||||
let _ = crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
|
||||
}
|
||||
Key::Comma | Key::Period if cmd && m.shift => {
|
||||
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
|
||||
let r = sel.range();
|
||||
let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
|
||||
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
|
||||
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
|
||||
typing_key(ui, true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &ViewerMap, e: &Edited, sel: std::ops::Range<usize>, caret: usize) {
|
||||
let t = app.tokens;
|
||||
let to_s = |p: Point| e.to_screen(map, (p.x, p.y));
|
||||
// Paragraph box and its handles.
|
||||
if e.doc.box_size.is_some() {
|
||||
let hs: Vec<Pos2> = e.handles().iter().map(|h| e.to_screen(map, *h)).collect();
|
||||
let corners = [hs[0], hs[2], hs[4], hs[6], hs[0]];
|
||||
for w in corners.windows(2) {
|
||||
painter.line_segment([w[0], w[1]], Stroke::new(1.0, Color32::from_white_alpha(170)));
|
||||
}
|
||||
for (i, h) in hs.iter().enumerate() {
|
||||
let r = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Middle);
|
||||
app.auto.add(&format!("viewer.textBox.handle.{i}"), r, "Text box handle");
|
||||
}
|
||||
}
|
||||
// Selection highlight.
|
||||
if !sel.is_empty() {
|
||||
for q in e.lay.selection_quads(sel.start, sel.end) {
|
||||
let pts: Vec<Pos2> = q.iter().map(|p| to_s(*p)).collect();
|
||||
painter.add(egui::Shape::convex_polygon(pts, t.accent.gamma_multiply(0.38), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
// Caret (blinking) and IME composition.
|
||||
let (a, b) = e.lay.caret(caret);
|
||||
let (sa, sb) = (to_s(a), to_s(b));
|
||||
let now = ui.input(|i| i.time);
|
||||
let preedit: Option<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));
|
||||
if let Some(pe) = preedit.filter(|s| !s.is_empty()) {
|
||||
let h = (sb - sa).length().max(10.0);
|
||||
let g = painter.layout_no_wrap(pe, egui::FontId::proportional(h * 0.7), Color32::WHITE);
|
||||
let r = Rect::from_min_size(sa, g.size());
|
||||
painter.rect_filled(r.expand(2.0), 2.0, Color32::from_black_alpha(160));
|
||||
painter.galley(sa, g, Color32::WHITE);
|
||||
painter.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, Color32::WHITE));
|
||||
} else if (now * 2.0) as i64 % 2 == 0 || !sel.is_empty() {
|
||||
painter.line_segment([sa, sb], Stroke::new(3.0, Color32::from_black_alpha(160)));
|
||||
painter.line_segment([sa, sb], Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
// The text region for automation, and the IME candidate window position.
|
||||
let r = e.region();
|
||||
let quad = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|p| e.to_screen(map, p));
|
||||
let rect = Rect::from_points(&quad);
|
||||
app.auto.add("viewer.textEdit", rect, &format!("Editing {}", e.layer.name));
|
||||
let cursor_rect = Rect::from_two_pos(sa, sb).expand2(vec2(1.0, 0.0));
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,689 @@
|
|||
//! Composition viewer tools: snapping (targets and the highlighted feedback), rulers with guides
|
||||
//! dragged out of them, the displayed frame (Show Channel, exposure, snapshots, region of
|
||||
//! interest, Wireframe Fast Previews) and the bottom control bar in After Effects' order.
|
||||
//!
|
||||
//! Edits go through engine commands (`view.*`, `comp.*`); the viewer calls in from small hooks.
|
||||
|
||||
use effectcraft_engine::commands::viewer_cmds::FastPreviews;
|
||||
use effectcraft_engine::project::{Comp, ItemId, LayerId};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_engine::viewer::{self as vw, Channel, Snap, SnapKind, SnapSource};
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::icons::Icon;
|
||||
use crate::state::Resolution;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
/// Snap distance in screen points.
|
||||
pub(crate) const SNAP_PX: f64 = 8.0;
|
||||
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
|
||||
|
||||
fn snap_id() -> egui::Id {
|
||||
egui::Id::new("viewer-snap")
|
||||
}
|
||||
|
||||
/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held.
|
||||
pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool {
|
||||
app.session.state.snapping != mods.command
|
||||
}
|
||||
|
||||
/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers'
|
||||
/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when
|
||||
/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns
|
||||
/// the comp-space correction (zero when nothing is near) and records the feedback for this frame.
|
||||
pub(crate) fn snap(
|
||||
app: &EffectcraftApp,
|
||||
ctx: &egui::Context,
|
||||
ectx: &EvalCtx,
|
||||
map: &ViewerMap,
|
||||
exclude: &[LayerId],
|
||||
sources: &[[f64; 2]],
|
||||
mods: egui::Modifiers,
|
||||
) -> [f64; 2] {
|
||||
let layers = snapping_on(app, mods);
|
||||
let v = &app.ui.viewer;
|
||||
let guides = v.snap_guides && v.guides;
|
||||
let grid = v.snap_grid;
|
||||
if sources.is_empty() || !(layers || guides || grid) {
|
||||
return [0.0; 2];
|
||||
}
|
||||
let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing };
|
||||
let mut targets = vw::targets(ectx, exclude, opts);
|
||||
if !layers {
|
||||
targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid));
|
||||
}
|
||||
match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) {
|
||||
Some(s) => {
|
||||
let d = s.delta;
|
||||
ctx.data_mut(|dd| dd.insert_temp(snap_id(), s));
|
||||
d
|
||||
}
|
||||
None => [0.0; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or
|
||||
/// grid line, and a cross where the feature landed.
|
||||
pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) {
|
||||
let Some(s) = ctx.data(|d| d.get_temp::<Snap>(snap_id())) else { return };
|
||||
ctx.data_mut(|d| d.remove::<Snap>(snap_id()));
|
||||
let stroke = Stroke::new(1.5, SNAP_COLOR);
|
||||
let area = map.area;
|
||||
for h in &s.hits {
|
||||
if let SnapSource::Layer(l) | SnapSource::Vertex(l) = h.source
|
||||
&& let Some(layer) = ectx.comp.layer(l)
|
||||
&& let Some(b) = effectcraft_engine::render::content_bounds(ectx, layer)
|
||||
{
|
||||
let (m, _) = ectx.layer_to_comp(layer);
|
||||
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::closed_line(q, stroke));
|
||||
}
|
||||
match h.kind {
|
||||
SnapKind::Point => {
|
||||
let c = map.to_screen(h.pos);
|
||||
painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle);
|
||||
}
|
||||
SnapKind::VLine => {
|
||||
let x = map.to_screen(h.pos).x;
|
||||
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR));
|
||||
}
|
||||
SnapKind::HLine => {
|
||||
let y = map.to_screen(h.pos).y;
|
||||
painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR));
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = map.to_screen(s.at);
|
||||
painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], stroke);
|
||||
painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], stroke);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rulers and guides
|
||||
|
||||
/// Ruler thickness in points.
|
||||
pub(crate) const RULER: f32 = 16.0;
|
||||
|
||||
/// The viewer area left for the image once the rulers (View ▸ Show Rulers) take their strips.
|
||||
pub(crate) fn inset(app: &EffectcraftApp, area: Rect) -> Rect {
|
||||
if app.ui.viewer.rulers { Rect::from_min_max(area.min + vec2(RULER, RULER), area.max) } else { area }
|
||||
}
|
||||
|
||||
fn nice(raw: f64) -> f64 {
|
||||
let mag = 10f64.powf(raw.max(1e-9).log10().floor());
|
||||
let n = raw / mag;
|
||||
mag * if n <= 1.0 {
|
||||
1.0
|
||||
} else if n <= 2.0 {
|
||||
2.0
|
||||
} else if n <= 5.0 {
|
||||
5.0
|
||||
} else {
|
||||
10.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the rulers (ticks relative to the zero point, live pointer markers) and handle their
|
||||
/// gestures: drag from a ruler to add a guide, drag the corner to move the zero point
|
||||
/// (double-click resets it).
|
||||
pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap, outer: Rect, inner: Rect) {
|
||||
if !app.ui.viewer.rulers {
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
let top = Rect::from_min_max(pos2(inner.min.x, outer.min.y), pos2(inner.max.x, inner.min.y));
|
||||
let left = Rect::from_min_max(pos2(outer.min.x, inner.min.y), pos2(inner.min.x, inner.max.y));
|
||||
let corner = Rect::from_min_max(outer.min, inner.min);
|
||||
let bg = t.panel_bg;
|
||||
for r in [top, left, corner] {
|
||||
p.rect_filled(r, 0.0, bg);
|
||||
}
|
||||
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
let o = app.ui.viewer.ruler_origin;
|
||||
let zoom = map.zoom as f64;
|
||||
let step = nice(64.0 / zoom.max(1e-6));
|
||||
let minor = step / if (step / nice(step / 2.0) - 2.0).abs() < 1e-9 { 2.0 } else { 5.0 };
|
||||
let tick = Stroke::new(1.0, t.text_faint);
|
||||
let font = Tokens::ui(9.0);
|
||||
let label = |v: f64| if step >= 1.0 { format!("{}", v.round() as i64) } else { format!("{v:.1}") };
|
||||
// Horizontal ruler (x).
|
||||
let pt = p.with_clip_rect(top);
|
||||
let (c0, c1) = (map.to_comp(top.left_top())[0], map.to_comp(top.right_top())[0]);
|
||||
let mut k = ((c0 - o[0]) / minor).floor() as i64;
|
||||
while (o[0] + k as f64 * minor) <= c1 {
|
||||
let cx = o[0] + k as f64 * minor;
|
||||
let x = map.to_screen([cx, 0.0]).x;
|
||||
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
|
||||
let is_major = major.abs() < minor * 0.01;
|
||||
pt.line_segment([pos2(x, top.max.y - if is_major { RULER - 2.0 } else { 4.0 }), pos2(x, top.max.y)], tick);
|
||||
if is_major {
|
||||
pt.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, label(cx - o[0]), font.clone(), t.text_dim);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
// Vertical ruler (y).
|
||||
let pl = p.with_clip_rect(left);
|
||||
let (r0, r1) = (map.to_comp(left.left_top())[1], map.to_comp(left.left_bottom())[1]);
|
||||
let mut k = ((r0 - o[1]) / minor).floor() as i64;
|
||||
while (o[1] + k as f64 * minor) <= r1 {
|
||||
let cy = o[1] + k as f64 * minor;
|
||||
let y = map.to_screen([0.0, cy]).y;
|
||||
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
|
||||
let is_major = major.abs() < minor * 0.01;
|
||||
pl.line_segment([pos2(left.max.x - if is_major { RULER - 2.0 } else { 4.0 }, y), pos2(left.max.x, y)], tick);
|
||||
if is_major {
|
||||
let g = pl.layout_no_wrap(label(cy - o[1]), font.clone(), t.text_dim);
|
||||
pl.add(egui::epaint::TextShape::new(pos2(left.min.x + 1.0, y + g.size().x + 2.0), g, t.text_dim).with_angle(-std::f32::consts::FRAC_PI_2));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
// Live pointer markers.
|
||||
if let Some(hp) = ui.ctx().pointer_hover_pos()
|
||||
&& inner.contains(hp)
|
||||
{
|
||||
let m = Stroke::new(1.0, t.accent);
|
||||
pt.line_segment([pos2(hp.x, top.min.y), pos2(hp.x, top.max.y)], m);
|
||||
pl.line_segment([pos2(left.min.x, hp.y), pos2(left.max.x, hp.y)], m);
|
||||
}
|
||||
// Zero point: drag the corner, double-click to reset.
|
||||
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick);
|
||||
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick);
|
||||
let cr =
|
||||
ui.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag()).on_hover_text("Ruler zero point (drag to move, double-click to reset)");
|
||||
app.auto.add("viewer.ruler.origin", corner, "Ruler zero point");
|
||||
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg")));
|
||||
if cr.dragged()
|
||||
&& let Some(hp) = cr.interact_pointer_pos()
|
||||
{
|
||||
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], Stroke::new(1.0, t.accent));
|
||||
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], Stroke::new(1.0, t.accent));
|
||||
}
|
||||
if cr.drag_stopped()
|
||||
&& let Some(hp) = cr.interact_pointer_pos()
|
||||
{
|
||||
let c = map.to_comp(hp);
|
||||
app.ui.viewer.ruler_origin = [c[0].round(), c[1].round()];
|
||||
}
|
||||
if cr.double_clicked() {
|
||||
app.ui.viewer.ruler_origin = [0.0, 0.0];
|
||||
}
|
||||
// Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one
|
||||
// vertical guides.
|
||||
for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text("Drag to create a guide");
|
||||
app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" });
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(hp) = resp.interact_pointer_pos()
|
||||
{
|
||||
let s = Stroke::new(1.0, guide_color(app));
|
||||
if vertical {
|
||||
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], s);
|
||||
} else {
|
||||
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], s);
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(hp) = resp.interact_pointer_pos()
|
||||
&& inner.contains(hp)
|
||||
{
|
||||
let c = map.to_comp(hp);
|
||||
let pos = if vertical { c[0] } else { c[1] }.round();
|
||||
let r = app.session.execute("view.addGuide", json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "position": pos}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.ui.viewer.guides = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guide_color(app: &EffectcraftApp) -> Color32 {
|
||||
let [r, g, b] = super::viewer::hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// The guide under a screen point (visible, unlocked guides only): (index, vertical).
|
||||
pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos: Pos2) -> Option<(usize, bool)> {
|
||||
if !app.ui.viewer.guides || app.ui.viewer.lock_guides {
|
||||
return None;
|
||||
}
|
||||
comp.guides.iter().enumerate().find_map(|(i, g)| {
|
||||
let s = map.to_screen([g.position, g.position]);
|
||||
let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() };
|
||||
(d < 4.0).then_some((i, g.vertical))
|
||||
})
|
||||
}
|
||||
|
||||
/// Proportional grid (Grid and guide options ▸ Proportional Grid): Settings ▸ Grids & Guides
|
||||
/// horizontal × vertical cells over the comp.
|
||||
pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, comp_rect: Rect) {
|
||||
if !app.ui.viewer.proportional_grid {
|
||||
return;
|
||||
}
|
||||
let g = &app.session.prefs.grids;
|
||||
let [r, gg, b] = super::viewer::hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
|
||||
let s = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 110));
|
||||
let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1));
|
||||
for i in 1..nx {
|
||||
let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32;
|
||||
painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s);
|
||||
}
|
||||
for i in 1..ny {
|
||||
let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32;
|
||||
painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the displayed frame
|
||||
|
||||
/// Remember the region of interest the displayed texture was rendered with.
|
||||
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi));
|
||||
}
|
||||
|
||||
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
|
||||
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
|
||||
}
|
||||
|
||||
/// Show Snapshot: the stored snapshot is on screen (button or F5 held).
|
||||
pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
|
||||
let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input();
|
||||
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
|
||||
}
|
||||
|
||||
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage {
|
||||
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
|
||||
vw::display_transform(&mut px, ch, colorized, stops);
|
||||
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
|
||||
}
|
||||
|
||||
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
|
||||
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
|
||||
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
|
||||
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
|
||||
let opts = app.session.state.viewer.clone();
|
||||
let snap = showing_snapshot(app, ctx);
|
||||
if opts.fast_previews == FastPreviews::Wireframe && !snap {
|
||||
let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32;
|
||||
let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect };
|
||||
for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) {
|
||||
let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue };
|
||||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0;
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
if snap {
|
||||
let Some(s) = app.session.snapshot.clone() else { return };
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-snapshot-tex");
|
||||
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
let img = crate::frames::to_color_image(&s.image);
|
||||
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) };
|
||||
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
tex
|
||||
}
|
||||
};
|
||||
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
|
||||
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
|
||||
return;
|
||||
}
|
||||
let Some((tex, k)) = app.viewer_tex.clone() else { return };
|
||||
if k.comp != cid.0 {
|
||||
return;
|
||||
}
|
||||
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
|
||||
let rect = match texture_roi(ctx) {
|
||||
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
|
||||
None => comp_rect,
|
||||
};
|
||||
if plain {
|
||||
painter.image(tex.id(), rect, uv, Color32::WHITE);
|
||||
return;
|
||||
}
|
||||
let Some(src) = app.viewer_image.clone() else { return };
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-display-tex");
|
||||
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
tex
|
||||
}
|
||||
};
|
||||
painter.image(tex.id(), rect, uv, Color32::WHITE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bottom bar
|
||||
|
||||
/// A popup list above `anchor` (selected entries highlighted); entries register
|
||||
/// `viewer.<auto>.<n>` automation ids.
|
||||
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
|
||||
let pid = egui::Id::new(id);
|
||||
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
|
||||
if !open {
|
||||
return None;
|
||||
}
|
||||
let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::<f32>() + 14.0;
|
||||
let pos = anchor.left_top() - vec2(0.0, h + 4.0);
|
||||
let mut chosen = None;
|
||||
let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(170.0);
|
||||
for (i, (label, on)) in items.iter().enumerate() {
|
||||
if label == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
let r = ui.selectable_label(*on, label.as_str());
|
||||
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
|
||||
if r.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let outside = ui.input(|i| i.pointer.any_pressed())
|
||||
&& !area.response.contains_pointer()
|
||||
&& !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default());
|
||||
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.data_mut(|d| d.insert_temp(pid.with("open"), false));
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
fn toggle_popup(ui: &egui::Ui, id: &str) {
|
||||
let pid = egui::Id::new(id).with("open");
|
||||
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
|
||||
ui.data_mut(|d| d.insert_temp(pid, !open));
|
||||
}
|
||||
|
||||
/// Magnification as After Effects shows it: "136.1 %", "100 %".
|
||||
pub(crate) fn magnification_label(pct: f32) -> String {
|
||||
if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") }
|
||||
}
|
||||
|
||||
/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets.
|
||||
pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String {
|
||||
match res {
|
||||
Resolution::Auto => {
|
||||
let r = if scale >= 0.99 {
|
||||
"Full"
|
||||
} else if scale >= 0.49 {
|
||||
"Half"
|
||||
} else if scale >= 0.33 {
|
||||
"Third"
|
||||
} else {
|
||||
"Quarter"
|
||||
};
|
||||
format!("({r})")
|
||||
}
|
||||
Resolution::Custom(n) => format!("Custom ({n})"),
|
||||
r => r.label().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution,
|
||||
/// transparency grid, mask and shape path visibility, region of interest, grid and guide
|
||||
/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D
|
||||
/// renderer and view, and the current time.
|
||||
pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(bar, 0.0, t.panel_bg);
|
||||
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
|
||||
let cy = bar.center().y;
|
||||
let mut x = bar.min.x + 8.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
|
||||
// Magnification.
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
|
||||
if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")).on_hover_text("Magnification ratio popup").clicked() {
|
||||
toggle_popup(ui, "vw-mag-pop");
|
||||
}
|
||||
app.auto.add("viewer.magnification", r, "Magnification");
|
||||
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
|
||||
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
|
||||
let items: Vec<(String, bool)> = MAGS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let on = match *m {
|
||||
"Fit" => app.ui.viewer.zoom.is_none(),
|
||||
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06),
|
||||
};
|
||||
(m.to_string(), on)
|
||||
})
|
||||
.collect();
|
||||
if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") {
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
app.ui.viewer.zoom = match MAGS[i] {
|
||||
"Fit" => None,
|
||||
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
|
||||
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
|
||||
};
|
||||
}
|
||||
x = r.max.x + 4.0;
|
||||
|
||||
// Resolution / down-sample factor.
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
|
||||
let scale = app.viewer_scale(zoom, ppp);
|
||||
if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
|
||||
.on_hover_text("Resolution/Down Sample Factor Popup")
|
||||
.clicked()
|
||||
{
|
||||
toggle_popup(ui, "vw-res-pop");
|
||||
}
|
||||
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
|
||||
let res = app.ui.viewer.res;
|
||||
let items: Vec<(String, bool)> = vec![
|
||||
("Full".into(), res == Resolution::Full),
|
||||
("Half".into(), res == Resolution::Half),
|
||||
("Third".into(), res == Resolution::Third),
|
||||
("Quarter".into(), res == Resolution::Quarter),
|
||||
("Custom...".into(), matches!(res, Resolution::Custom(_))),
|
||||
("-".into(), false),
|
||||
("Auto".into(), res == Resolution::Auto),
|
||||
];
|
||||
if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") {
|
||||
let ctx = ui.ctx().clone();
|
||||
let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i];
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
|
||||
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) {
|
||||
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip);
|
||||
auto.add(&format!("viewer.{id}"), r, tip);
|
||||
*x += 24.0;
|
||||
(resp.clicked(), r)
|
||||
};
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 {
|
||||
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 {
|
||||
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
|
||||
}
|
||||
// Region of interest: with none, arm drawing (the next drag draws it); with one, clear it.
|
||||
let has_roi = app.session.state.region_of_interest.is_some();
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 {
|
||||
if has_roi {
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null}));
|
||||
app.ui.viewer.roi_draw = false;
|
||||
} else {
|
||||
app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw;
|
||||
}
|
||||
}
|
||||
// Grid and guide options.
|
||||
let v = &app.ui.viewer;
|
||||
let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers;
|
||||
let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options");
|
||||
if clicked {
|
||||
toggle_popup(ui, "vw-grid-pop");
|
||||
}
|
||||
let v = &app.ui.viewer;
|
||||
let items = vec![
|
||||
("Title/Action Safe".to_string(), v.safe_margins),
|
||||
("Proportional Grid".to_string(), v.proportional_grid),
|
||||
("Grid".to_string(), v.grid),
|
||||
("Guides".to_string(), v.guides),
|
||||
("Rulers".to_string(), v.rulers),
|
||||
];
|
||||
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
|
||||
let v = &mut app.ui.viewer;
|
||||
match i {
|
||||
0 => v.safe_margins = !v.safe_margins,
|
||||
1 => v.proportional_grid = !v.proportional_grid,
|
||||
2 => v.grid = !v.grid,
|
||||
3 => v.guides = !v.guides,
|
||||
_ => v.rulers = !v.rulers,
|
||||
}
|
||||
}
|
||||
// Show Channel and Color Management Settings.
|
||||
let vo = app.session.state.viewer.clone();
|
||||
let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings");
|
||||
if clicked {
|
||||
toggle_popup(ui, "vw-channel-pop");
|
||||
}
|
||||
let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect();
|
||||
items.push(("-".into(), false));
|
||||
items.push(("Colorize".into(), vo.colorized));
|
||||
if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") {
|
||||
let params =
|
||||
if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) };
|
||||
let _ = app.session.execute("view.channel", params);
|
||||
}
|
||||
// Exposure.
|
||||
let exposed = vo.exposure != 0.0;
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 {
|
||||
let _ = app.session.execute("view.resetExposure", json!({}));
|
||||
}
|
||||
let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
|
||||
let mut ev = vo.exposure as f64;
|
||||
let resp = ui.put(
|
||||
er,
|
||||
egui::DragValue::new(&mut ev)
|
||||
.speed(0.05)
|
||||
.range(-40.0..=40.0)
|
||||
.fixed_decimals(1)
|
||||
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
|
||||
);
|
||||
let resp = resp.on_hover_text("Adjust Exposure (stops)");
|
||||
app.auto.add("viewer.exposure", er, "Adjust Exposure");
|
||||
if resp.changed() {
|
||||
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
|
||||
}
|
||||
x = er.max.x + 4.0;
|
||||
// Snapshots.
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
|
||||
let scale = app.viewer_scale(zoom, ppp);
|
||||
match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) {
|
||||
Ok(_) => app.ui.status = "Snapshot taken".into(),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
let showing = showing_snapshot(app, ui.ctx());
|
||||
let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)");
|
||||
// Show Snapshot works while the button is held, like F5.
|
||||
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o));
|
||||
if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot {
|
||||
let _ = app.session.execute("view.showSnapshot", json!({"value": held}));
|
||||
}
|
||||
// Fast Previews.
|
||||
let fp = vo.fast_previews;
|
||||
let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews");
|
||||
if clicked {
|
||||
toggle_popup(ui, "vw-fast-pop");
|
||||
}
|
||||
let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect();
|
||||
if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") {
|
||||
let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()}));
|
||||
}
|
||||
x += 4.0;
|
||||
// 3D renderer + view.
|
||||
if comp.has_3d() {
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
|
||||
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
|
||||
app.auto.add("viewer.renderer3d", r, "3D Renderer");
|
||||
x = r.max.x + 4.0;
|
||||
let cid = app.session.active_comp_id();
|
||||
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
|
||||
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
|
||||
toggle_popup(ui, "vw-cam-pop");
|
||||
}
|
||||
app.auto.add("viewer.view3d", r, "3D View");
|
||||
use effectcraft_engine::render::three_d::View3D;
|
||||
let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect();
|
||||
if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") {
|
||||
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
}
|
||||
// Current time.
|
||||
let tc = crate::panels::timecode(&app.session, comp, time);
|
||||
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
|
||||
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
|
||||
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
|
||||
app.auto.add("viewer.timecode", tr, "Current time");
|
||||
// Right: render time.
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bar_labels_match_after_effects() {
|
||||
assert_eq!(magnification_label(136.08), "136.1 %");
|
||||
assert_eq!(magnification_label(100.0), "100 %");
|
||||
assert_eq!(magnification_label(49.97), "50 %");
|
||||
assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)");
|
||||
assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)");
|
||||
assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter");
|
||||
assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,10 @@ pub enum Tool {
|
|||
Polygon,
|
||||
Star,
|
||||
Pen,
|
||||
PenAdd,
|
||||
PenDelete,
|
||||
PenConvert,
|
||||
MaskFeather,
|
||||
Type,
|
||||
TypeVertical,
|
||||
Brush,
|
||||
|
|
@ -49,7 +53,7 @@ impl Tool {
|
|||
&[Tool::Rotate],
|
||||
&[Tool::PanBehind],
|
||||
&[Tool::Rectangle, Tool::RoundedRect, Tool::Ellipse, Tool::Polygon, Tool::Star],
|
||||
&[Tool::Pen],
|
||||
&[Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather],
|
||||
&[Tool::Type, Tool::TypeVertical],
|
||||
&[Tool::Brush],
|
||||
&[Tool::Clone],
|
||||
|
|
@ -57,7 +61,7 @@ impl Tool {
|
|||
&[Tool::RotoBrush],
|
||||
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
|
||||
];
|
||||
pub const ALL: [Tool; 25] = [
|
||||
pub const ALL: [Tool; 29] = [
|
||||
Tool::Selection,
|
||||
Tool::Hand,
|
||||
Tool::Zoom,
|
||||
|
|
@ -72,6 +76,10 @@ impl Tool {
|
|||
Tool::Polygon,
|
||||
Tool::Star,
|
||||
Tool::Pen,
|
||||
Tool::PenAdd,
|
||||
Tool::PenDelete,
|
||||
Tool::PenConvert,
|
||||
Tool::MaskFeather,
|
||||
Tool::Type,
|
||||
Tool::TypeVertical,
|
||||
Tool::Brush,
|
||||
|
|
@ -100,6 +108,10 @@ impl Tool {
|
|||
Tool::Polygon => "Polygon Tool",
|
||||
Tool::Star => "Star Tool",
|
||||
Tool::Pen => "Pen Tool",
|
||||
Tool::PenAdd => "Add Vertex Tool",
|
||||
Tool::PenDelete => "Delete Vertex Tool",
|
||||
Tool::PenConvert => "Convert Vertex Tool",
|
||||
Tool::MaskFeather => "Mask Feather Tool",
|
||||
Tool::Type => "Horizontal Type Tool",
|
||||
Tool::TypeVertical => "Vertical Type Tool",
|
||||
Tool::Brush => "Brush Tool",
|
||||
|
|
@ -124,7 +136,7 @@ impl Tool {
|
|||
Tool::Rotate => Some("W"),
|
||||
Tool::PanBehind => Some("Y"),
|
||||
Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => Some("Q"),
|
||||
Tool::Pen => Some("G"),
|
||||
Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"),
|
||||
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
|
||||
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
|
||||
Tool::RotoBrush => Some("Alt+W"),
|
||||
|
|
@ -147,6 +159,10 @@ impl Tool {
|
|||
Tool::Polygon => Icon::Polygon,
|
||||
Tool::Star => Icon::Star,
|
||||
Tool::Pen => Icon::Pen,
|
||||
Tool::PenAdd => Icon::PenAdd,
|
||||
Tool::PenDelete => Icon::PenDelete,
|
||||
Tool::PenConvert => Icon::PenConvert,
|
||||
Tool::MaskFeather => Icon::MaskFeather,
|
||||
Tool::Type => Icon::Type,
|
||||
Tool::TypeVertical => Icon::TypeVertical,
|
||||
Tool::Brush => Icon::Brush,
|
||||
|
|
@ -180,6 +196,10 @@ impl Tool {
|
|||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The Pen slot's tools (Pen, Add / Delete / Convert Vertex, Mask Feather).
|
||||
pub fn is_pen(self) -> bool {
|
||||
matches!(self, Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather)
|
||||
}
|
||||
pub fn is_shape(self) -> bool {
|
||||
matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star)
|
||||
}
|
||||
|
|
@ -194,6 +214,8 @@ pub enum Resolution {
|
|||
Half,
|
||||
Third,
|
||||
Quarter,
|
||||
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
|
||||
Custom(u8),
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
|
|
@ -205,6 +227,7 @@ impl Resolution {
|
|||
Resolution::Half => "Half",
|
||||
Resolution::Third => "Third",
|
||||
Resolution::Quarter => "Quarter",
|
||||
Resolution::Custom(_) => "Custom",
|
||||
}
|
||||
}
|
||||
/// Render scale for a viewer magnification (and display pixel density).
|
||||
|
|
@ -226,6 +249,7 @@ impl Resolution {
|
|||
Resolution::Half => 0.5,
|
||||
Resolution::Third => 1.0 / 3.0,
|
||||
Resolution::Quarter => 0.25,
|
||||
Resolution::Custom(n) => 1.0 / n.max(1) as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -251,6 +275,15 @@ pub struct ViewerState {
|
|||
pub snap_guides: bool,
|
||||
pub lock_guides: bool,
|
||||
pub snap_grid: bool,
|
||||
/// Grid and guide options ▸ Proportional Grid.
|
||||
pub proportional_grid: bool,
|
||||
/// Rulers zero point (comp pixels), moved by dragging the ruler corner.
|
||||
pub ruler_origin: [f64; 2],
|
||||
/// Region of Interest button armed: the next drag in the viewer draws the region.
|
||||
pub roi_draw: bool,
|
||||
/// The pointer is dragging in the viewer this frame (Adaptive Resolution).
|
||||
#[serde(skip)]
|
||||
pub interacting: bool,
|
||||
/// View ▸ Panel Background Color (`None` = theme default).
|
||||
pub pasteboard: Option<[u8; 3]>,
|
||||
pub custom_pasteboard: [u8; 3],
|
||||
|
|
@ -274,6 +307,10 @@ impl Default for ViewerState {
|
|||
snap_guides: false,
|
||||
lock_guides: false,
|
||||
snap_grid: false,
|
||||
proportional_grid: false,
|
||||
ruler_origin: [0.0, 0.0],
|
||||
roi_draw: false,
|
||||
interacting: false,
|
||||
pasteboard: None,
|
||||
custom_pasteboard: [0x80, 0x80, 0x80],
|
||||
}
|
||||
|
|
@ -311,6 +348,15 @@ pub struct TimelineState {
|
|||
/// Manual graph value range (when auto-zoom is off).
|
||||
#[serde(default)]
|
||||
pub graph_range: Option<(f64, f64)>,
|
||||
/// Graph Editor ▸ Snap: key drags snap to the current time, other keys and frames.
|
||||
#[serde(default = "yes")]
|
||||
pub graph_snap: bool,
|
||||
/// Graph Editor ▸ Show Reference Graph (the other graph type, faint, behind).
|
||||
#[serde(default)]
|
||||
pub graph_reference: bool,
|
||||
/// Graph Editor ▸ Show Transform Box When Multiple Keys Are Selected.
|
||||
#[serde(default = "yes")]
|
||||
pub graph_transform_box: bool,
|
||||
/// Properties whose inline expression editor is collapsed.
|
||||
#[serde(default)]
|
||||
pub expr_closed: BTreeSet<u64>,
|
||||
|
|
@ -341,6 +387,9 @@ impl Default for TimelineState {
|
|||
graph_show_selected: true,
|
||||
graph_auto_zoom: true,
|
||||
graph_range: None,
|
||||
graph_snap: true,
|
||||
graph_reference: false,
|
||||
graph_transform_box: true,
|
||||
expr_closed: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -350,6 +399,10 @@ fn default_project_sort() -> String {
|
|||
"name".into()
|
||||
}
|
||||
|
||||
pub fn default_project_columns() -> Vec<String> {
|
||||
["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 {
|
||||
|
|
@ -378,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,
|
||||
|
|
@ -388,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<String, DockNode>,
|
||||
/// Undocked (floating) panel groups.
|
||||
#[serde(default)]
|
||||
pub floating: Vec<crate::dock::Floating>,
|
||||
/// Floating panels of the saved workspaces.
|
||||
#[serde(default)]
|
||||
pub saved_floating: std::collections::BTreeMap<String, Vec<crate::dock::Floating>>,
|
||||
/// The panel maximized to fill the dock area (`~`), if any.
|
||||
#[serde(default)]
|
||||
pub maximized: Option<PanelKind>,
|
||||
pub focused: PanelKind,
|
||||
pub viewer: ViewerState,
|
||||
pub timeline: TimelineState,
|
||||
|
|
@ -405,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<String>,
|
||||
/// Effect Controls twirl state (group uids that are collapsed).
|
||||
pub fx_closed: BTreeSet<u64>,
|
||||
/// Slider params whose slider row is twirled open (AE hides sliders by default).
|
||||
|
|
@ -456,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<usize>,
|
||||
/// 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 {
|
||||
|
|
@ -466,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(),
|
||||
|
|
@ -478,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,
|
||||
|
|
@ -500,6 +618,8 @@ impl Default for UiState {
|
|||
tracker_source: None,
|
||||
layer_panel: None,
|
||||
layer_view: None,
|
||||
anim_tools: AnimToolsState::default(),
|
||||
mini_flowchart: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Headless checks for on-canvas text editing in the viewer and the Character / Paragraph panels
|
||||
//! acting on the selection (egui_kittest, UI logic only: no GPU needed).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::text_edit::layer_doc;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
EffectcraftApp::new(s)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness<'_, EffectcraftApp>, t: &str) {
|
||||
h.input_mut().events.push(Event::Text(t.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn edited(h: &Harness<'_, EffectcraftApp>) -> (u64, String, usize, usize) {
|
||||
let s = &h.state().session;
|
||||
let e = s.state.text_edit.clone().expect("editing");
|
||||
(e.layer.0, layer_doc(s, e.layer).unwrap().text, e.anchor, e.caret)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_click_type_edit_and_commit() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
|
||||
h.run_steps(1);
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
// Click: a point text layer in edit mode, caret ready.
|
||||
click(&mut h, comp.center(), Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1);
|
||||
type_text(&mut h, "Hello");
|
||||
type_text(&mut h, " world");
|
||||
let (lid, text, a, c) = edited(&h);
|
||||
assert_eq!((text.as_str(), a, c), ("Hello world", 11, 11));
|
||||
assert!(h.state().auto.find("viewer.textEdit").is_some(), "edit region registered");
|
||||
// Arrow keys, Shift-extend, Backspace.
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::SHIFT);
|
||||
assert_eq!((edited(&h).2, edited(&h).3), (10, 9));
|
||||
key(&mut h, Key::Backspace, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).1, "Hello word");
|
||||
// Word jump and Home / End.
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).3, 0);
|
||||
key(&mut h, Key::ArrowRight, Modifiers::COMMAND);
|
||||
assert_eq!(edited(&h).3, 5);
|
||||
key(&mut h, Key::End, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).3, 10);
|
||||
// Enter starts a new paragraph.
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
type_text(&mut h, "two");
|
||||
assert_eq!(edited(&h).1, "Hello word\ntwo");
|
||||
// Select all, then the Character panel styles the selection only.
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert!(edited(&h).3 <= 10, "up to the first line");
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
key(&mut h, Key::End, Modifiers::SHIFT);
|
||||
let (_, _, a, c) = edited(&h);
|
||||
assert_eq!((a, c), (0, 10));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
let bold = rect(&h, "character.fauxBold");
|
||||
click(&mut h, bold.center(), Modifiers::NONE);
|
||||
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
assert!(doc.style_at(2).faux_bold, "selection is bold");
|
||||
assert!(!doc.style_at(12).faux_bold, "second paragraph untouched");
|
||||
assert_eq!(doc.runs().len(), 2);
|
||||
// Undo the style; Escape commits.
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer_doc(&h.state().session, LayerId(lid)).unwrap().is_uniform());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.execute("text.setSelection", json!({"start": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().session.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_enters_editing_and_selects_words() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let lid =
|
||||
s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
// A point inside "beta" (comp space → window).
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
let k = comp.width() / 640.0;
|
||||
let p = comp.min + egui::vec2(330.0 * k, 180.0 * k);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
let e = h.state().session.state.text_edit.clone().expect("double-click edits text");
|
||||
assert_eq!(e.layer.0, lid);
|
||||
assert_eq!((e.anchor.min(e.caret), e.anchor.max(e.caret)), (0, 10), "everything selected");
|
||||
// Double-clicking inside the edited text selects a word.
|
||||
h.run_steps(40);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
let e = h.state().session.state.text_edit.clone().unwrap();
|
||||
assert_eq!((e.anchor, e.caret), (6, 10));
|
||||
// Typing replaces it; Cmd+Z undoes while editing.
|
||||
type_text(&mut h, "gamma");
|
||||
assert_eq!(edited(&h).1, "alpha gamma");
|
||||
key(&mut h, Key::Z, Modifiers::COMMAND);
|
||||
assert_eq!(edited(&h).1, "alpha beta");
|
||||
// Paragraph panel: alignment applies to the edited paragraph.
|
||||
h.state_mut().show_panel(PanelKind::Paragraph);
|
||||
h.run_steps(3);
|
||||
let r = rect(&h, "paragraph.right");
|
||||
click(&mut h, r.center(), Modifiers::NONE);
|
||||
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
assert_eq!(doc.justify, effectcraft_engine::keyframe::Justify::Right);
|
||||
for id in
|
||||
["paragraph.indentLeft", "paragraph.spaceBefore", "paragraph.direction", "paragraph.composer", "paragraph.hangingPunctuation", "paragraph.justifyAll"]
|
||||
{
|
||||
assert!(h.state().auto.find(id).is_some(), "missing {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_drag_makes_paragraph_text_with_box_handles() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
|
||||
h.run_steps(1);
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
let a = comp.min + egui::vec2(comp.width() * 0.2, comp.height() * 0.2);
|
||||
let b = comp.min + egui::vec2(comp.width() * 0.7, comp.height() * 0.6);
|
||||
h.input_mut().events.push(Event::PointerMoved(a));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let (lid, _, _, _) = edited(&h);
|
||||
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
let bs = doc.box_size.expect("paragraph text");
|
||||
assert!((bs[0] - 320.0).abs() < 4.0 && (bs[1] - 144.0).abs() < 4.0, "{bs:?}");
|
||||
type_text(&mut h, "Some words that wrap inside the box when there are enough of them");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("viewer.textBox.handle.4").is_some());
|
||||
// Dragging the bottom-right handle widens the box.
|
||||
let hr = rect(&h, "viewer.textBox.handle.4").center();
|
||||
let to = hr + egui::vec2(60.0, 0.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(hr));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: hr, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(hr + (to - hr) * (i as f32 / 6.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let doc2 = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
assert!(doc2.box_size.unwrap()[0] > bs[0] + 20.0, "{:?}", doc2.box_size);
|
||||
assert!(h.state().session.state.text_edit.is_some(), "still editing after resizing");
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of
|
||||
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
|
||||
//! (egui_kittest, UI logic only).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::state::Tool;
|
||||
use egui::{Event, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
EffectcraftApp::new(s)
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Comp pixel → screen point.
|
||||
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
|
||||
let c = rect(h, "viewer.comp");
|
||||
let z = c.width() / 640.0;
|
||||
c.min + vec2(p[0] * z, p[1] * z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_bar_in_after_effects_order() {
|
||||
let h = harness();
|
||||
let order = [
|
||||
"viewer.magnification",
|
||||
"viewer.resolution",
|
||||
"viewer.transparency",
|
||||
"viewer.masks",
|
||||
"viewer.roi",
|
||||
"viewer.grid",
|
||||
"viewer.channel",
|
||||
"viewer.resetExposure",
|
||||
"viewer.exposure",
|
||||
"viewer.snapshot",
|
||||
"viewer.showSnapshot",
|
||||
"viewer.fastPreviews",
|
||||
"viewer.timecode",
|
||||
];
|
||||
let xs: Vec<f32> = order.iter().map(|id| rect(&h, id).min.x).collect();
|
||||
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
|
||||
let mag = &h.state().auto.find("viewer.magnification").unwrap().label;
|
||||
assert_eq!(mag, "Magnification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_of_interest_is_drawn_in_the_viewer() {
|
||||
let mut h = harness();
|
||||
let at = rect(&h, "viewer.roi").center();
|
||||
click(&mut h, at);
|
||||
assert!(h.state().ui.viewer.roi_draw);
|
||||
let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0]));
|
||||
drag(&mut h, a, b);
|
||||
let r = h.state().session.state.region_of_interest.expect("roi");
|
||||
assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}");
|
||||
assert!(!h.state().ui.viewer.roi_draw);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("viewer.regionOfInterest").is_some());
|
||||
// The button clears it again.
|
||||
let at = rect(&h, "viewer.roi").center();
|
||||
click(&mut h, at);
|
||||
assert!(h.state().session.state.region_of_interest.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rulers_make_guides_and_guides_move() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.viewer.rulers = true;
|
||||
h.run_steps(3);
|
||||
let top = rect(&h, "viewer.ruler.top");
|
||||
let to = screen(&h, [0.0, 120.0]);
|
||||
drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y));
|
||||
let g = h.state().session.active_comp().unwrap().guides.clone();
|
||||
assert_eq!(g.len(), 1);
|
||||
assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}");
|
||||
// Drag the guide down with the Selection tool.
|
||||
let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y);
|
||||
let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y);
|
||||
drag(&mut h, from, to2);
|
||||
let g = h.state().session.active_comp().unwrap().guides.clone();
|
||||
assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
|
||||
let mut h = harness();
|
||||
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
// Grab the box at its centre and drop it 3 px from the comp centre: it snaps there.
|
||||
let from = screen(&h, [100.0, 100.0]);
|
||||
let to = screen(&h, [323.0, 182.0]);
|
||||
drag(&mut h, from, to);
|
||||
let pos = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
|
||||
l.props.prop("transform/position").unwrap().value.as_vec3()
|
||||
};
|
||||
let p = pos(&h);
|
||||
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}");
|
||||
// Snapping off: the same drag lands where the pointer is.
|
||||
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
drag(&mut h, from, to);
|
||||
let p = pos(&h);
|
||||
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pen_draws_a_closed_shape_layer() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
h.run_steps(2);
|
||||
let n0 = h.state().session.active_comp().unwrap().layers.len();
|
||||
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] {
|
||||
let at = screen(&h, p);
|
||||
click(&mut h, at);
|
||||
}
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
assert_eq!(comp.layers.len(), n0 + 1);
|
||||
let l = &comp.layers[0];
|
||||
assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape));
|
||||
let g = l.props.sub("contents").unwrap().groups().next().unwrap();
|
||||
assert_eq!(g.name, "Shape 1");
|
||||
let path = g.sub("contents").unwrap().groups().next().unwrap();
|
||||
let sp = path.get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert_eq!(sp.vertices.len(), 3);
|
||||
assert!(sp.closed);
|
||||
assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices);
|
||||
// G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen.
|
||||
let ctx = h.ctx.clone();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap();
|
||||
seen.push(h.state().ui.tool);
|
||||
}
|
||||
assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]);
|
||||
// Add Vertex on the first segment.
|
||||
h.state_mut().ui.tool = Tool::PenAdd;
|
||||
h.run_steps(2);
|
||||
let at = screen(&h, [300.0, 110.0]);
|
||||
click(&mut h, at);
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
let sp = comp.layers[0]
|
||||
.props
|
||||
.sub("contents")
|
||||
.unwrap()
|
||||
.groups()
|
||||
.next()
|
||||
.unwrap()
|
||||
.sub("contents")
|
||||
.unwrap()
|
||||
.groups()
|
||||
.next()
|
||||
.unwrap()
|
||||
.get("path")
|
||||
.unwrap()
|
||||
.value
|
||||
.as_path()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(sp.vertices.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_key_drag_edits_that_key() {
|
||||
let mut h = harness();
|
||||
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap();
|
||||
s.execute("view.snapping", json!({"value": false})).unwrap();
|
||||
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some());
|
||||
let from = screen(&h, [500.0, 100.0]);
|
||||
let to2 = screen(&h, [500.0, 300.0]);
|
||||
drag(&mut h, from, to2);
|
||||
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
|
||||
let k = &l.props.prop("transform/position").unwrap().keys;
|
||||
let v1 = k[1].value.as_vec3();
|
||||
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
|
||||
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_transform_box_scales_selected_keys_in_time() {
|
||||
let mut h = harness();
|
||||
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap();
|
||||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] {
|
||||
assert!(h.state().auto.find(id).is_some(), "missing {id}");
|
||||
}
|
||||
// Drag the right edge handle to the right: the keys spread out in time, the first stays.
|
||||
let r = rect(&h, "timeline.graph.transformBox.5");
|
||||
let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center();
|
||||
let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center();
|
||||
let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0);
|
||||
drag(&mut h, r.center(), to);
|
||||
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
|
||||
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(ts[0], 0.0);
|
||||
assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}");
|
||||
assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![0.0, 50.0, 100.0]);
|
||||
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
|
||||
assert_eq!(undo, 1, "one undo step per drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_alt_drag_scales_a_key_group_in_time() {
|
||||
let mut h = harness();
|
||||
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
let mut ks: Vec<Pos2> =
|
||||
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
|
||||
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
assert_eq!(ks.len(), 3, "{ks:?}");
|
||||
let (k0, k2) = (ks[0], ks[2]);
|
||||
// Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key.
|
||||
let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y);
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
h.input_mut().events.push(Event::PointerMoved(k2));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(alt));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt });
|
||||
h.run_steps(2);
|
||||
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
|
||||
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(ts.len(), 3);
|
||||
assert_eq!(ts[0], 0.0);
|
||||
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
|
||||
}
|
||||
|
|
@ -100,6 +100,29 @@ effectcraft-cli run clip.ecproj track.motion '{"layer":"#2"}' \
|
|||
track.analyze '{"wait":true}' track.apply '{}' --save
|
||||
```
|
||||
|
||||
### Text: styles and editing
|
||||
|
||||
Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the
|
||||
whole layer, or only characters `range: [start, end]` (character indices): character attributes
|
||||
(`font`, `size`, `fill`, `tracking`, `kerning: metrics|optical|<1/1000 em>`, `tsume`,
|
||||
`baseline: superscript|subscript`, `allCaps`…) split the text into runs; paragraph attributes
|
||||
(`justify`, `indentLeft`, `indentFirst`, `spaceBefore`, `direction`, `composer`,
|
||||
`hangingPunctuation`…) apply to the paragraphs the range touches. Editing mirrors the Type tool:
|
||||
|
||||
1. `layer.newText {"text":"", "box":[100,100,600,300], "edit":true}` makes paragraph text and
|
||||
starts editing (`position` instead of `box` makes point text; `vertical: true` vertical type).
|
||||
2. `text.insert {"text":"Hello world"}` types at the caret (replacing the selection);
|
||||
`text.setSelection {"start":6,"end":11}` selects "world"; `text.moveCaret {"to":"wordLeft",
|
||||
"extend":true}` moves like the arrow keys; `text.delete {"word":true}` is Alt+Backspace.
|
||||
3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the
|
||||
Character panel does the same); with an empty range it sets the style the next typed text takes.
|
||||
4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly`
|
||||
work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed).
|
||||
|
||||
In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters
|
||||
(`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a
|
||||
styled document.
|
||||
|
||||
## CLI
|
||||
|
||||
Each invocation runs a headless engine with no window. It opens the demo project unless you pass
|
||||
|
|
|
|||
|
|
@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.
|
|||
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
|
||||
`effects.item.<name>`. Use `ui.elements` to see what is on screen.
|
||||
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
|
||||
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,
|
||||
`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.<menu>Item.<n>`),
|
||||
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
|
||||
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.freeTransform.handle.<i>` and
|
||||
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
|
||||
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
|
||||
|
||||
Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`,
|
||||
`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`,
|
||||
`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`,
|
||||
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),
|
||||
`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and
|
||||
shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph
|
||||
Editor transform box, timeline Alt-drag scaling).
|
||||
|
||||
The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol;
|
||||
see [agents.md](agents.md).
|
||||
|
|
|
|||
|
|
@ -63,17 +63,17 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
|
|||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
|
||||
| Automation | 76% | 3.3 | a JavaScript scripting object model |
|
||||
| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes |
|
||||
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
|
||||
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
|
||||
| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch |
|
||||
| Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents |
|
||||
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
|
||||
| Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
|
||||
| Web | 60% | 2.5 | threads, browser storage, audio |
|
||||
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
|
||||
| Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects |
|
||||
| Interface | 55% | 10.4 | drag-to-dock and floating panels, viewer rulers/snapping/channels/snapshots, preferences, native macOS menus |
|
||||
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
|
||||
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, free transform and RotoBezier |
|
||||
| Preview | 43% | 8.1 | GPU compositor, disk cache, region of interest, snapshots and exposure |
|
||||
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points |
|
||||
| Preview | 43% | 8.1 | GPU compositor, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking |
|
||||
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
|
||||
|
||||
|
|
@ -87,18 +87,18 @@ Mocha and Cineware are third-party and not counted.
|
|||
|
||||
## Highest-value gaps, in order
|
||||
|
||||
1. On-canvas text editing and per-character styles.
|
||||
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
|
||||
2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.
|
||||
3. Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.
|
||||
3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13)
|
||||
4. A GPU (wgpu) compositor, then GPU effects.
|
||||
5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier.
|
||||
6. Motion-path handles in the viewer; graph editor transform box and snapping.
|
||||
5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer.
|
||||
6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8)
|
||||
7. Point tracking and stabilization (in progress).
|
||||
8. Frame blending, collapse transformations, slip edit.
|
||||
9. A real 8/16/32-bit pipeline with linear blending and colour management.
|
||||
10. Drag-to-dock and floating panels, saved workspaces, native macOS menus.
|
||||
11. Auto-save, crash recovery, recent projects (in progress).
|
||||
12. Expression gaps: `sampleImage`, the `sourceText` style API, `footage()`.
|
||||
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
|
||||
13. Lottie (in progress), WebM, SVG and PSD import.
|
||||
14. Puppet and paint tools (in progress).
|
||||
15. Preferences and a shortcut editor that can rebind (in progress); real Wiggler, Smoother and Motion Sketch; the marker dialog.
|
||||
|
|
|
|||
Loading…
Reference in New Issue