From 468dc13cfe9a385022671fb1a5bfab1bf541b8bd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:16:05 -0400 Subject: [PATCH 1/4] M0.13: viewer snapping, rulers, channels, snapshots, ROI Composition viewer interaction parity with After Effects: - Snapping (View > Snapping, the Tools bar checkbox, Cmd/Ctrl inverts while dragging): layer edges, centres and anchor points, mask and shape-path vertices, comp edges/centre, guides (Snap to Guides) and grid (Snap to Grid), with highlighted target box / line / cross feedback. Pure maths in engine::viewer (nearest target within tolerance, priority ties). - Rulers (Cmd+R) with live pointer markers and a draggable zero point; guides dragged out of the rulers, moved in the viewer, dragged off to remove (view.moveGuide / view.removeGuide), lock respected; proportional grid. - Show Channel (RGB/Red/Green/Blue/Alpha/RGB Straight, Colorize), exposure (+/- stops in linear light, reset), Take Snapshot (Shift+F5) with a stored render and Show Snapshot (F5 held / button held), Fast Previews (Off, Adaptive Resolution, Draft, Fast Draft, Wireframe), Resolution > Custom. - Region of interest: drawn in the viewer; RenderOpts.roi renders only that rectangle (crop of the full render, tested), Crop Comp to ROI uses it. - Bottom bar in AE 2026 order ("136.1 %", "(Full)"). - Shape Pen (SHP-1): Pen on a shape layer / nothing selected draws Shape n groups (Path 1, Stroke 1, Fill 1) via shape.newPath; the G slot cycles Add / Delete / Convert Vertex and Mask Feather; mask.* vertex commands work on shape paths; mask.insertVertex / mask.convertVertex; marquee vertex selection, Alt-drag/click converts, double-click free transform box (path.freeTransform on masks and shape paths). - Motion paths (ANM-3): keyframe dots and spatial tangent handles, dragging keys (keys.set) and tangents (keys.setSpatialTangents), parented layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 4 + crates/engine/src/commands/frontend.rs | 1 + crates/engine/src/commands/key_transform.rs | 153 ++++ crates/engine/src/commands/mask.rs | 127 +++- crates/engine/src/commands/mod.rs | 6 + crates/engine/src/commands/paths.rs | 9 +- crates/engine/src/commands/stubs.rs | 1 - crates/engine/src/commands/viewer_cmds.rs | 305 ++++++++ crates/engine/src/lib.rs | 14 + crates/engine/src/menus.rs | 2 + crates/engine/src/tests_viewer.rs | 384 +++++++++++ crates/engine/src/viewer.rs | 372 ++++++++++ crates/export/src/lib.rs | 1 + crates/render/src/lib.rs | 55 +- crates/render/src/shapes.rs | 22 + crates/render/src/tests_roi.rs | 73 ++ crates/ui-egui/src/header.rs | 6 +- crates/ui-egui/src/icons.rs | 23 + crates/ui-egui/src/lib.rs | 20 +- crates/ui-egui/src/menus.rs | 30 + crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/viewer.rs | 549 +++++++-------- crates/ui-egui/src/panels/viewer_overlays.rs | 528 ++++++++++++++ crates/ui-egui/src/panels/viewer_tools.rs | 689 +++++++++++++++++++ crates/ui-egui/src/state.rs | 55 +- crates/ui-egui/tests/ui_viewer.rs | 224 ++++++ docs/control-protocol.md | 16 +- docs/parity.md | 16 +- 28 files changed, 3370 insertions(+), 317 deletions(-) create mode 100644 crates/engine/src/commands/key_transform.rs create mode 100644 crates/engine/src/commands/viewer_cmds.rs create mode 100644 crates/engine/src/tests_viewer.rs create mode 100644 crates/engine/src/viewer.rs create mode 100644 crates/render/src/tests_roi.rs create mode 100644 crates/ui-egui/src/panels/viewer_overlays.rs create mode 100644 crates/ui-egui/src/panels/viewer_tools.rs create mode 100644 crates/ui-egui/tests/ui_viewer.rs diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 5ce1cec1..437c122b 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -502,6 +502,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result { fn purge_caches(s: &mut Session, p: &Value) -> Result { 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})) diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 6f9f6098..e4c7857e 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -42,6 +42,7 @@ pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/commands/key_transform.rs b/crates/engine/src/commands/key_transform.rs new file mode 100644 index 00000000..82037d52 --- /dev/null +++ b/crates/engine/src/commands/key_transform.rs @@ -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 { + 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> = 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> = 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, Vec) = 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 { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 8c671ecd..39894b58 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -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 { + 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(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> Result<(Uid, T)> { let (cid, lid) = layer_p(s, p, label)?; @@ -41,8 +60,7 @@ fn edit_path(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 { 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 { + 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 { + 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 { + 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 { let (cid, lid) = layer_p(s, p, "mask.remove")?; let key = p.get("mask").cloned(); @@ -263,6 +368,8 @@ pub fn specs() -> Vec { 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)), ] diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index d0fcfd21..ccb16935 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -11,6 +11,7 @@ mod file; mod file_more; mod frontend; mod help; +mod key_transform; mod layer; mod layer_menu; mod layer_time; @@ -31,6 +32,9 @@ 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; @@ -106,6 +110,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()); diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 0c0436ab..57736101 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -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 { 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 }; diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index c04f1f98..1134f184 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -56,7 +56,6 @@ pub fn specs() -> Vec { 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}"), diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs new file mode 100644 index 00000000..c0161510 --- /dev/null +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + s.state.viewer.exposure = 0.0; + Ok(json!(0.0)) +} + +fn take_snapshot(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)> { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b5c5f43..7f79c4fb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod render_queue; mod session_settings; pub mod shortcuts; pub mod tracking; +pub mod viewer; use std::sync::Arc; @@ -178,6 +179,9 @@ pub struct EditorState { /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). #[serde(default)] pub hide_locked_masks: bool, + /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). + #[serde(default)] + pub viewer: commands::viewer_cmds::ViewOptions, } fn one_view() -> u8 { @@ -254,6 +258,8 @@ pub struct Session { pub shortcut_table: std::sync::OnceLock, /// Auto-save bookkeeping. pub autosave: autosave::AutoSaveState, + /// The viewer snapshot (Take Snapshot / Show Snapshot). + pub snapshot: Option, } impl Default for Session { @@ -283,6 +289,7 @@ impl Default for Session { ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), + snapshot: None, } } } @@ -407,6 +414,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) @@ -535,6 +547,8 @@ mod tests_text; 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. diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 606ad18c..a935ebc0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "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 diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs new file mode 100644 index 00000000..d6072c58 --- /dev/null +++ b/crates/engine/src/tests_viewer.rs @@ -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 = 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 = 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![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()); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs new file mode 100644 index 00000000..0c002fa1 --- /dev/null +++ b/crates/engine/src/viewer.rs @@ -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, + /// 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 { + 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 { + 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 { + 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, +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1616926d..b2daf0f9 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc7db2d4..a6cee23d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -138,11 +138,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, + /// 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 } } } @@ -189,6 +192,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; @@ -441,11 +464,34 @@ 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); - 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])) } /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. @@ -595,7 +641,8 @@ impl<'a> Renderer<'a> { 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 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 below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale }; let adjusted = self.apply_effects(ctx, layer, below, true); if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height { return; @@ -631,6 +678,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 diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index dfcbe8d8..25eb6090 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -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 { + fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option { + 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 } } diff --git a/crates/render/src/tests_roi.rs b/crates/render/src/tests_roi.rs new file mode 100644 index 00000000..9adc8a8e --- /dev/null +++ b/crates/render/src/tests_roi.rs @@ -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)); +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 07e5b844..b2fa142e 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 816da289..d28cb950 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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)]); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7627c096..ffbf41f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -283,7 +283,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 +299,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 +330,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 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7ec29b30..595538e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -131,6 +131,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), @@ -195,6 +196,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; @@ -395,6 +401,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), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index fffe91bc..239b84d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -23,6 +23,8 @@ pub mod text_panels; pub mod timeline; pub mod tracker; pub mod viewer; +pub mod viewer_overlays; +pub mod viewer_tools; pub mod waveform; use effectcraft_engine::Session; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a02c9b3a..d174322a 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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, - /// 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 = Vec::new(); + let mut key_hits: Vec = Vec::new(); + let mut paths: Vec = 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 = 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()) @@ -741,6 +683,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 +756,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 +791,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 +825,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 = 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 +923,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 +996,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 = 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); @@ -1032,7 +1062,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string()))); } } - Tool::Pen => {} + 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::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,7 +1113,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if resp.double_clicked() - && app.ui.tool != Tool::Pen + && 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() + && !app.ui.tool.is_pen() && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); @@ -1085,7 +1147,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 +1245,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::(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. @@ -1333,136 +1442,6 @@ fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa } } -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 = ["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::().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 = 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 = 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(); diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs new file mode 100644 index 00000000..08cc32b5 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -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, + /// 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 { + 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 { + let k = effectcraft_engine::render::kurbo_path(sp); + let mut pts: Vec = 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, + hits: &mut Vec, + paths: &mut Vec, +) { + 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, + 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) { + 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 { + 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 = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect(); + let idx: Vec = 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 { + 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 { + 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 { + let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::>(), 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 { + 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, + } +} diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs new file mode 100644 index 00000000..79688e19 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -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_id())) else { return }; + ctx.data_mut(|d| d.remove::(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 = [[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::>(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 = [[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..` automation ids. +fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option { + 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::() + 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::().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::().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)"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 515b6d9f..43ca11cb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, @@ -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(), } } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs new file mode 100644 index 00000000..db3f9748 --- /dev/null +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -0,0 +1,224 @@ +//! 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 = 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]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f7c8b964..449a1898 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel. `header.workspace.Animation`, `project.item.`, `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..twirl`, `timeline.prop..stopwatch`, `timeline.key..`, `timeline.cti`, `effectControls.prop..value` and -`effects.item.`. Use `ui.elements` to see what is on screen. +`effects.item.`. 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.Item.`), +`viewer.ruler.top|left|origin`, `viewer.mask..vertex.`, `viewer.shapePath..vertex.`, +`viewer.motionPath..[.in|.out]`, `viewer.freeTransform.handle.` and +`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.]`, +`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). diff --git a/docs/parity.md b/docs/parity.md index df6b963a..5fb2c816 100644 --- a/docs/parity.md +++ b/docs/parity.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 | +| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) | | Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents | | 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 | @@ -89,10 +89,10 @@ Mocha and Cineware are third-party and not counted. 1. On-canvas text editing and per-character styles. 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. From f455b8b2fbe91a7c6ee5d45da9633b3a7f1077c0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:22:51 -0400 Subject: [PATCH 2/4] M5.8: motion paths + graph transform box Graph Editor (ANM-4) and timeline keyframe tools: - Transform box around two or more selected keys: corners scale time and value about the opposite corner, edges one axis, the inside moves the keys (keys.transform, one undo step per drag, times land on frames). - Snap: dragged key times snap to the current time and other keys. - Show Reference Graph: the other graph type, faint, behind the curves. - Timeline Alt-drag of a key group's first or last key scales the group in time about the other end. - keys.transform takes dims (only the shown dimensions change). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/graph.rs | 52 ++++- crates/ui-egui/src/panels/graph_tools.rs | 281 +++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 14 +- crates/ui-egui/tests/ui_viewer.rs | 73 ++++++ 5 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/graph_tools.rs diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7a3072e6..655fe619 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -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)> = 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 = 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 = 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 = 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()?; diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs new file mode 100644 index 00000000..c42fd329 --- /dev/null +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -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 = None; + let mut resp_any: Option = 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::(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 = 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::(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 = 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::(alt_id())) else { return }; + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(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::(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)]) { + for (c, pts) in curves { + p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35)))); + } + let _ = plot; +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 239b84d3..16d1c0ee 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -8,6 +8,7 @@ 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; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ab85e8eb..e1563c44 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1320,7 +1320,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; @@ -1364,6 +1372,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); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index db3f9748..db739954 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -222,3 +222,76 @@ fn motion_path_key_drag_edits_that_key() { 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 = 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![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 = + 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 = 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:?}"); +} From f78435927b175e1a832c965f4d4f23872259430f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:34:13 -0400 Subject: [PATCH 3/4] M9.9: text style runs + paragraph settings TextDoc gains character style runs (font, size, colours, tracking, leading, baseline shift, h/v scale, tsume, faux styles, caps, super/subscript, kerning metrics/optical/manual, ligatures) and per-paragraph settings (justification with last-line modes, indents, space before/after, direction, composer, hanging punctuation), serde-compatible with single-style documents. Layout handles mixed runs on a line (shared baseline, largest leading), every-line composer, editing geometry (carets, hit testing, selection quads) and basic vertical type. Renderer colours per run. Expressions get the sourceText style API (style, getStyleAt, createStyle, setters with character ranges). Co-Authored-By: Claude Opus 5.5 --- crates/expr/Cargo.toml | 1 + crates/expr/src/host.rs | 21 + crates/expr/src/prelude.js | 147 +++- crates/expr/src/runtime.rs | 81 ++- crates/expr/src/tests.rs | 54 ++ crates/keyframe/src/lib.rs | 2 + crates/keyframe/src/text_doc.rs | 1112 +++++++++++++++++++++++++++++++ crates/keyframe/src/value.rs | 78 +-- crates/render/src/text.rs | 37 +- crates/text/src/layout.rs | 680 ++++++++++++++++--- crates/text/src/lib.rs | 318 +++++++-- 11 files changed, 2278 insertions(+), 253 deletions(-) create mode 100644 crates/keyframe/src/text_doc.rs diff --git a/crates/expr/Cargo.toml b/crates/expr/Cargo.toml index 613162e1..6f157698 100644 --- a/crates/expr/Cargo.toml +++ b/crates/expr/Cargo.toml @@ -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 } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 23362970..4b92e224 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -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 }, + /// 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 = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect(); + let paras: Vec = 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))?; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 1e3a61ab..67ddf458 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -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; } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 65177476..af7ed9b6 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -70,7 +70,14 @@ pub enum Out { Num(f64), Str(String), Arr(Vec), - 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= 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 { } 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, String> { let v = o.get(name, ctx).map_err(err)?; @@ -408,6 +426,9 @@ pub fn to_value(out: &Out, current: &Value) -> Result { Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::>().join(","), Out::Undefined | Out::Null => String::new(), Out::Path { .. } => "[object Path]".into(), + Out::Style(j) => { + serde_json::from_str::(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::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 { }) } +/// 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::(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}") } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 8ff451bd..fd02acef 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index d97ec53d..731e167c 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -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. diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs new file mode 100644 index 00000000..9652b91a --- /dev/null +++ b/crates/keyframe/src/text_doc.rs @@ -0,0 +1,1112 @@ +//! Source Text documents: text with character style runs and per-paragraph settings. +//! +//! A [`TextDoc`] keeps its *base* character style and paragraph settings in flat fields (the +//! style of the first character and the settings of the first paragraph), which is all a +//! single-style document needs and what older projects contain. Mixed formatting is stored in +//! [`TextDoc::runs`] (character style runs covering the text, by character count) and +//! [`TextDoc::paragraphs`] (one entry per `\n`-separated paragraph). Both lists are empty while the +//! document is uniform, so single-style documents serialize exactly as before. +//! +//! Positions are **character** indices (Unicode scalar values), as in After Effects' expression +//! and scripting APIs. + +use std::ops::Range; + +use serde::{Deserialize, Serialize}; +use serde_json::Value as J; + +/// Horizontal paragraph alignment (AE's seven Paragraph panel buttons). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Justify { + #[default] + Left, + Center, + Right, + JustifyLastLeft, + JustifyLastCenter, + JustifyLastRight, + JustifyAll, +} + +impl Justify { + pub const ALL: [Justify; 7] = + [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLastLeft, Justify::JustifyLastCenter, Justify::JustifyLastRight, Justify::JustifyAll]; + /// The `layer.setText` / expression name. + pub fn key(self) -> &'static str { + match self { + Justify::Left => "left", + Justify::Center => "center", + Justify::Right => "right", + Justify::JustifyLastLeft => "justifyLeft", + Justify::JustifyLastCenter => "justifyCenter", + Justify::JustifyLastRight => "justifyRight", + Justify::JustifyAll => "justifyAll", + } + } + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() { + "left" | "leftjustify" => Justify::Left, + "center" | "centre" | "centerjustify" => Justify::Center, + "right" | "rightjustify" => Justify::Right, + "justify" | "justifyleft" | "justifylastleft" | "fulljustifylastlineleft" => Justify::JustifyLastLeft, + "justifycenter" | "justifylastcenter" | "fulljustifylastlinecenter" => Justify::JustifyLastCenter, + "justifyright" | "justifylastright" | "fulljustifylastlineright" => Justify::JustifyLastRight, + "justifyall" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll, + _ => return None, + }) + } +} + +/// Kerning between a character and the one before it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub enum Kerning { + /// The font's kerning pairs (`kern` / GPOS). + #[default] + Metrics, + /// Spacing from the glyph outlines. + Optical, + /// A manual value in 1/1000 em before this character (replaces metrics for the pair). + Manual(f64), +} + +impl Kerning { + pub fn parse(v: &J) -> Option { + match v { + J::Number(n) => n.as_f64().map(Kerning::Manual), + J::String(s) => match s.to_ascii_lowercase().as_str() { + "metrics" | "metric" | "auto" => Some(Kerning::Metrics), + "optical" => Some(Kerning::Optical), + "none" | "0" | "manual" => Some(Kerning::Manual(0.0)), + s => s.parse::().ok().map(Kerning::Manual), + }, + _ => None, + } + } + pub fn to_json(self) -> J { + match self { + Kerning::Metrics => J::from("metrics"), + Kerning::Optical => J::from("optical"), + Kerning::Manual(v) => J::from(v), + } + } +} + +/// Superscript / subscript. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BaselineOption { + #[default] + Normal, + Superscript, + Subscript, +} + +impl BaselineOption { + pub fn key(self) -> &'static str { + match self { + BaselineOption::Normal => "normal", + BaselineOption::Superscript => "superscript", + BaselineOption::Subscript => "subscript", + } + } + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "normal" | "none" => BaselineOption::Normal, + "superscript" | "super" => BaselineOption::Superscript, + "subscript" | "sub" => BaselineOption::Subscript, + _ => return None, + }) + } +} + +/// Paragraph direction. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Direction { + #[default] + Ltr, + Rtl, +} + +/// Line-breaking composer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Composer { + /// Balances the breaks of the whole paragraph (AE's default for new text). + #[default] + EveryLine, + /// Breaks each line as soon as it is full. + SingleLine, +} + +fn single_line() -> Composer { + Composer::SingleLine +} +fn yes() -> bool { + true +} + +/// The formatting of a run of characters (Character panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CharStyle { + 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, + /// 1/1000 em after each character. + pub tracking: f64, + /// Baseline-to-baseline distance; None = auto (120% of the size). + pub leading: Option, + /// Pixels, positive = up. + pub baseline_shift: f64, + pub h_scale: f64, + pub v_scale: f64, + /// Tsume: percent (0–100) of the space around each character removed. + pub tsume: f64, + pub faux_bold: bool, + pub faux_italic: bool, + pub all_caps: bool, + pub small_caps: bool, + pub baseline: BaselineOption, + pub kerning: Kerning, + pub ligatures: bool, +} + +impl Default for CharStyle { + fn default() -> Self { + TextDoc::default().base_style() + } +} + +impl CharStyle { + /// The line advance this style asks for (Auto Leading = 120% of the size). + pub fn line_advance(&self) -> f64 { + self.leading.unwrap_or(self.size * 1.2) + } +} + +/// A run of `len` characters sharing a style. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StyleRun { + pub len: usize, + pub style: CharStyle, +} + +/// Paragraph panel settings of one paragraph. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaStyle { + pub justify: Justify, + /// Start (left in LTR) and end margins, and the first line's extra indent, in pixels. + pub indent_left: f64, + pub indent_right: f64, + pub indent_first: f64, + pub space_before: f64, + pub space_after: f64, + pub direction: Direction, + pub composer: Composer, + /// Roman hanging punctuation: punctuation at the edges of paragraph text hangs outside the box. + pub hanging_punctuation: bool, +} + +impl Default for ParaStyle { + fn default() -> Self { + TextDoc::default().base_para() + } +} + +/// The value of a text layer's Source Text (hold-interpolated). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +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, + 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], + pub tsume: f64, + pub baseline: BaselineOption, + pub kerning: Kerning, + #[serde(default = "yes")] + pub ligatures: bool, + pub indent_left: f64, + pub indent_right: f64, + pub indent_first: f64, + pub space_before: f64, + pub space_after: f64, + pub direction: Direction, + /// Documents saved before composers existed broke lines greedily. + #[serde(default = "single_line")] + pub composer: Composer, + pub hanging_punctuation: bool, + /// Vertical type (columns right to left, characters upright). + pub vertical: bool, + /// Character style runs covering the text (empty = the whole text uses the base style). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub runs: Vec, + /// One entry per paragraph (empty = every paragraph uses the base settings). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, +} + +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], + tsume: 0.0, + baseline: BaselineOption::Normal, + kerning: Kerning::Metrics, + ligatures: true, + indent_left: 0.0, + indent_right: 0.0, + indent_first: 0.0, + space_before: 0.0, + space_after: 0.0, + direction: Direction::Ltr, + composer: Composer::EveryLine, + hanging_punctuation: false, + vertical: false, + runs: Vec::new(), + paragraphs: Vec::new(), + } + } +} + +/// Split `runs` so a run starts at character `at`; returns that run's index. +fn split_at(runs: &mut Vec, at: usize) -> usize { + let mut pos = 0; + for i in 0..runs.len() { + if pos == at { + return i; + } + let len = runs[i].len; + if at < pos + len { + let mut tail = runs[i].clone(); + tail.len = pos + len - at; + runs[i].len = at - pos; + runs.insert(i + 1, tail); + return i + 1; + } + pos += len; + } + runs.len() +} + +impl TextDoc { + /// A document with `text` in the default style. + pub fn plain(text: &str) -> TextDoc { + TextDoc { text: text.into(), ..Default::default() } + } + + pub fn char_len(&self) -> usize { + self.text.chars().count() + } + + /// Byte offset of character `ci` (clamped to the end). + pub fn byte_of(&self, ci: usize) -> usize { + self.text.char_indices().nth(ci).map_or(self.text.len(), |(b, _)| b) + } + + /// Character index of byte offset `b`. + pub fn char_of_byte(&self, b: usize) -> usize { + self.text.char_indices().take_while(|(i, _)| *i < b).count() + } + + /// The text of characters `r`. + pub fn substring(&self, r: Range) -> String { + self.text.chars().skip(r.start).take(r.end.saturating_sub(r.start)).collect() + } + + /// The base (first character's) style. + pub fn base_style(&self) -> CharStyle { + CharStyle { + font: self.font.clone(), + style: self.style.clone(), + size: self.size, + fill: self.fill, + stroke: self.stroke, + stroke_width: self.stroke_width, + apply_fill: self.apply_fill, + apply_stroke: self.apply_stroke, + tracking: self.tracking, + leading: self.leading, + baseline_shift: self.baseline_shift, + h_scale: self.h_scale, + v_scale: self.v_scale, + tsume: self.tsume, + faux_bold: self.faux_bold, + faux_italic: self.faux_italic, + all_caps: self.all_caps, + small_caps: self.small_caps, + baseline: self.baseline, + kerning: self.kerning, + ligatures: self.ligatures, + } + } + + fn write_base(&mut self, s: &CharStyle) { + self.font = s.font.clone(); + self.style = s.style.clone(); + self.size = s.size; + self.fill = s.fill; + self.stroke = s.stroke; + self.stroke_width = s.stroke_width; + self.apply_fill = s.apply_fill; + self.apply_stroke = s.apply_stroke; + self.tracking = s.tracking; + self.leading = s.leading; + self.baseline_shift = s.baseline_shift; + self.h_scale = s.h_scale; + self.v_scale = s.v_scale; + self.tsume = s.tsume; + self.faux_bold = s.faux_bold; + self.faux_italic = s.faux_italic; + self.all_caps = s.all_caps; + self.small_caps = s.small_caps; + self.baseline = s.baseline; + self.kerning = s.kerning; + self.ligatures = s.ligatures; + } + + /// The base (first paragraph's) settings. + pub fn base_para(&self) -> ParaStyle { + ParaStyle { + justify: self.justify, + indent_left: self.indent_left, + indent_right: self.indent_right, + indent_first: self.indent_first, + space_before: self.space_before, + space_after: self.space_after, + direction: self.direction, + composer: self.composer, + hanging_punctuation: self.hanging_punctuation, + } + } + + fn write_base_para(&mut self, p: &ParaStyle) { + self.justify = p.justify; + self.indent_left = p.indent_left; + self.indent_right = p.indent_right; + self.indent_first = p.indent_first; + self.space_before = p.space_before; + self.space_after = p.space_after; + self.direction = p.direction; + self.composer = p.composer; + self.hanging_punctuation = p.hanging_punctuation; + } + + /// Whether every character shares one style. + pub fn is_uniform(&self) -> bool { + self.runs.is_empty() + } + + /// Style runs covering the whole text (at least one run, of length 0 for empty text). + pub fn runs(&self) -> Vec { + let n = self.char_len(); + if self.runs.is_empty() { + return vec![StyleRun { len: n, style: self.base_style() }]; + } + let mut out = Vec::with_capacity(self.runs.len()); + let mut pos = 0; + for r in &self.runs { + if pos >= n { + break; + } + let len = r.len.min(n - pos); + if len > 0 { + out.push(StyleRun { len, style: r.style.clone() }); + } + pos += len; + } + match out.last_mut() { + Some(last) if pos < n => last.len += n - pos, + None => out.push(StyleRun { len: n, style: self.runs[0].style.clone() }), + _ => {} + } + out + } + + /// Replace the style runs: zero-length runs are dropped, equal neighbours merged, the base + /// style becomes the first run's, and a single run collapses to the uniform form. + pub fn set_runs(&mut self, runs: Vec) { + let first = runs.first().map(|r| r.style.clone()); + let mut out: Vec = Vec::with_capacity(runs.len()); + for r in runs.into_iter().filter(|r| r.len > 0) { + match out.last_mut() { + Some(last) if last.style == r.style => last.len += r.len, + _ => out.push(r), + } + } + if let Some(s) = out.first().map(|r| r.style.clone()).or(first) { + self.write_base(&s); + } + if out.len() <= 1 { + out.clear(); + } + self.runs = out; + } + + /// The style of character `ci` (the last character's past the end). + pub fn style_at(&self, ci: usize) -> CharStyle { + if self.runs.is_empty() { + return self.base_style(); + } + let mut pos = 0; + let runs = self.runs(); + for r in &runs { + if ci < pos + r.len { + return r.style.clone(); + } + pos += r.len; + } + runs.last().map(|r| r.style.clone()).unwrap_or_else(|| self.base_style()) + } + + /// The style text typed at caret `at` takes: the character before it, or the first one. + pub fn insertion_style(&self, at: usize) -> CharStyle { + self.style_at(at.saturating_sub(1)) + } + + /// Change the style of characters `r` (clamped). + pub fn apply_style(&mut self, r: Range, mut f: impl FnMut(&mut CharStyle)) { + let n = self.char_len(); + let (a, b) = (r.start.min(n), r.end.min(n)); + if n == 0 { + // Empty text: the base style is what the next typed character gets. + let mut s = self.base_style(); + f(&mut s); + self.write_base(&s); + return; + } + if a >= b { + return; + } + let mut runs = self.runs(); + let i = split_at(&mut runs, a); + let j = split_at(&mut runs, b); + for run in &mut runs[i..j] { + f(&mut run.style); + } + self.set_runs(runs); + } + + /// Apply a style to every character. + pub fn apply_style_all(&mut self, f: impl FnMut(&mut CharStyle)) { + let n = self.char_len(); + self.apply_style(0..n.max(1), f); + } + + /// Number of paragraphs (`\n`-separated). + pub fn para_count(&self) -> usize { + self.text.matches('\n').count() + 1 + } + + /// Character ranges of the paragraphs (without their newline). + pub fn para_ranges(&self) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + let mut i = 0; + for c in self.text.chars() { + if c == '\n' { + out.push(start..i); + start = i + 1; + } + i += 1; + } + out.push(start..i); + out + } + + /// Paragraph index of character / caret position `ci`. + pub fn para_of(&self, ci: usize) -> usize { + self.text.chars().take(ci).filter(|c| *c == '\n').count() + } + + /// Paragraph settings, one per paragraph. + pub fn paras(&self) -> Vec { + let n = self.para_count(); + let base = self.base_para(); + if self.paragraphs.is_empty() { + return vec![base; n]; + } + let mut v: Vec = self.paragraphs.iter().take(n).cloned().collect(); + let last = v.last().cloned().unwrap_or(base); + v.resize(n, last); + v + } + + /// Settings of paragraph `i`. + pub fn para(&self, i: usize) -> ParaStyle { + if self.paragraphs.is_empty() { + return self.base_para(); + } + let v = self.paras(); + v[i.min(v.len() - 1)].clone() + } + + /// Replace the paragraph settings (collapsed when they're all equal). + pub fn set_paras(&mut self, mut v: Vec) { + if let Some(first) = v.first().cloned() { + self.write_base_para(&first); + } + if v.iter().all(|p| Some(p) == v.first()) { + v.clear(); + } + self.paragraphs = v; + } + + /// Change the paragraphs touched by characters `r` (an empty range: the caret's paragraph). + pub fn apply_para(&mut self, r: Range, mut f: impl FnMut(&mut ParaStyle)) { + let pa = self.para_of(r.start); + let pb = if r.end > r.start { self.para_of(r.end - 1) } else { pa }; + let mut v = self.paras(); + for p in v.iter_mut().take(pb + 1).skip(pa) { + f(p); + } + self.set_paras(v); + } + + /// Replace characters `r` with `s`. The new text takes `style`, or the style of the first + /// replaced character, or (inserting) the style of the character before the caret. + pub fn replace_range(&mut self, r: Range, s: &str, style: Option<&CharStyle>) { + let n = self.char_len(); + let (a, b) = (r.start.min(n), r.end.min(n).max(r.start.min(n))); + let st = match style { + Some(s) => s.clone(), + None if b > a => self.style_at(a), + None => self.insertion_style(a), + }; + let mut runs = self.runs(); + let i = split_at(&mut runs, a); + let j = split_at(&mut runs, b); + let ins = s.chars().count(); + runs.splice(i..j, std::iter::once(StyleRun { len: ins, style: st })); + // Paragraphs: the joined paragraph keeps the first one's settings; new paragraphs copy it. + let mut paras = self.paras(); + let pa = self.para_of(a); + let pb = self.para_of(b); + let added = s.matches('\n').count(); + let keep = paras[pa].clone(); + paras.splice(pa..=pb.min(paras.len() - 1), std::iter::repeat_n(keep, added + 1)); + let (ba, bb) = (self.byte_of(a), self.byte_of(b)); + self.text.replace_range(ba..bb, s); + self.set_runs(runs); + self.set_paras(paras); + } + + /// Replace the whole text, keeping the first character's style and the paragraph settings. + pub fn set_text(&mut self, s: &str) { + if self.runs.is_empty() && self.paragraphs.is_empty() { + self.text = s.to_string(); + return; + } + let n = self.char_len(); + let st = self.style_at(0); + self.replace_range(0..n, s, Some(&st)); + } + + /// Characters `r` as their own document (formatting kept). + pub fn slice(&self, r: Range) -> TextDoc { + let n = self.char_len(); + let (a, b) = (r.start.min(n), r.end.min(n).max(r.start.min(n))); + let mut runs = self.runs(); + let i = split_at(&mut runs, a); + let j = split_at(&mut runs, b); + let sub: Vec = runs[i..j].to_vec(); + let paras = self.paras(); + let (pa, pb) = (self.para_of(a), self.para_of(b)); + let mut d = self.clone(); + d.text = self.substring(a..b); + d.runs.clear(); + d.paragraphs.clear(); + if sub.is_empty() { + d.write_base(&self.style_at(a)); + } + d.set_runs(sub); + d.set_paras(paras[pa..=pb.min(paras.len() - 1)].to_vec()); + d + } + + /// Replace characters `r` with another document's text and character formatting. + pub fn insert_doc(&mut self, r: Range, frag: &TextDoc) { + let a = r.start.min(self.char_len()); + self.replace_range(r, &frag.text, None); + let mut pos = a; + for run in frag.runs() { + let st = run.style.clone(); + self.apply_style(pos..pos + run.len, |s| *s = st.clone()); + pos += run.len; + } + } + + /// Re-establish the invariants after direct field edits (runs clamped, lists collapsed). + pub fn normalize(&mut self) { + let runs = self.runs(); + if !self.runs.is_empty() { + self.set_runs(runs); + } + if !self.paragraphs.is_empty() { + let p = self.paras(); + self.set_paras(p); + } + } + + /// Set a document-level, character or paragraph attribute by its `layer.setText` key over + /// characters `r` (None = the whole text). Returns false for unknown keys. + pub fn set_attr(&mut self, key: &str, v: &J, r: Option>) -> Result { + match key { + "strokeOverFill" => { + self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?; + return Ok(true); + } + "vertical" => { + self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?; + return Ok(true); + } + "box" => { + match v { + J::Null => self.box_size = None, + J::Array(a) if a.len() >= 4 => { + let g = |i: usize| a[i].as_f64().unwrap_or(0.0); + self.box_pos = [g(0), g(1)]; + self.box_size = Some([g(2).max(1.0), g(3).max(1.0)]); + } + _ => return Err("box: expected [x, y, width, height] or null".into()), + } + return Ok(true); + } + _ => {} + } + let n = self.char_len(); + let r = r.unwrap_or(0..n); + let mut probe = ParaStyle::default(); + if apply_para_attr(&mut probe, key, v)? { + let mut err = None; + self.apply_para(r, |p| { + if let Err(e) = apply_para_attr(p, key, v) { + err = Some(e); + } + }); + return err.map_or(Ok(true), Err); + } + let mut probe = CharStyle::default(); + if apply_char_attr(&mut probe, key, v)? { + if r.start >= r.end && n > 0 { + return Ok(true); + } + self.apply_style(r.start..r.end.max(r.start + usize::from(n == 0)), |s| { + let _ = apply_char_attr(s, key, v); + }); + return Ok(true); + } + Ok(false) + } +} + +fn color_json(v: &J) -> Option<[f32; 4]> { + match v { + J::String(s) => { + let s = s.trim_start_matches('#'); + let h = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|x| x as f32 / 255.0); + Some([h(0)?, h(2)?, h(4)?, if s.len() >= 8 { h(6)? } else { 1.0 }]) + } + J::Array(a) if a.len() >= 3 => { + let g = |i: usize, d: f64| a.get(i).and_then(J::as_f64).unwrap_or(d) as f32; + Some([g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]) + } + _ => None, + } +} + +fn num(key: &str, v: &J) -> Result { + v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number")) +} +fn flag(key: &str, v: &J) -> Result { + v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean")) +} + +/// Character attribute keys of `layer.setText` (also used by the expression style API). +pub const CHAR_ATTRS: &[&str] = &[ + "font", + "style", + "size", + "fill", + "stroke", + "strokeWidth", + "applyFill", + "applyStroke", + "tracking", + "leading", + "baselineShift", + "hScale", + "vScale", + "tsume", + "fauxBold", + "fauxItalic", + "allCaps", + "smallCaps", + "baseline", + "superscript", + "subscript", + "kerning", + "ligatures", +]; + +/// Paragraph attribute keys of `layer.setText`. +pub const PARA_ATTRS: &[&str] = + &["justify", "indentLeft", "indentRight", "indentFirst", "spaceBefore", "spaceAfter", "direction", "composer", "hangingPunctuation"]; + +/// Set one character attribute; Ok(false) for keys that aren't character attributes. +pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { + match key { + "font" => s.font = v.as_str().ok_or("font: expected a string")?.to_string(), + "style" => s.style = v.as_str().ok_or("style: expected a string")?.to_string(), + "size" => s.size = num(key, v)?.clamp(0.1, 10_000.0), + "fill" => s.fill = color_json(v).ok_or("fill: expected #rrggbb or [r, g, b]")?, + "stroke" => { + s.stroke = color_json(v).ok_or("stroke: expected #rrggbb or [r, g, b]")?; + s.apply_stroke = true; + } + "strokeWidth" => { + s.stroke_width = num(key, v)?.max(0.0); + s.apply_stroke = s.stroke_width > 0.0; + } + "applyFill" => s.apply_fill = flag(key, v)?, + "applyStroke" => s.apply_stroke = flag(key, v)?, + "tracking" => s.tracking = num(key, v)?, + "leading" => { + s.leading = match v { + J::Number(n) => n.as_f64().map(|x| x.max(0.0)), + J::String(_) | J::Null => None, + J::Bool(true) => None, + _ => return Err("leading: expected a number or \"auto\"".into()), + } + } + "baselineShift" => s.baseline_shift = num(key, v)?, + "hScale" => s.h_scale = num(key, v)?.clamp(1.0, 1000.0), + "vScale" => s.v_scale = num(key, v)?.clamp(1.0, 1000.0), + "tsume" => s.tsume = num(key, v)?.clamp(0.0, 100.0), + "fauxBold" => s.faux_bold = flag(key, v)?, + "fauxItalic" => s.faux_italic = flag(key, v)?, + "allCaps" => { + s.all_caps = flag(key, v)?; + if s.all_caps { + s.small_caps = false; + } + } + "smallCaps" => { + s.small_caps = flag(key, v)?; + if s.small_caps { + s.all_caps = false; + } + } + "baseline" => s.baseline = v.as_str().and_then(BaselineOption::parse).ok_or("baseline: expected normal|superscript|subscript")?, + "superscript" => { + s.baseline = if flag(key, v)? { BaselineOption::Superscript } else { BaselineOption::Normal }; + } + "subscript" => { + s.baseline = if flag(key, v)? { BaselineOption::Subscript } else { BaselineOption::Normal }; + } + "kerning" => s.kerning = Kerning::parse(v).ok_or("kerning: expected metrics|optical|number")?, + "ligatures" => s.ligatures = flag(key, v)?, + _ => return Ok(false), + } + Ok(true) +} + +/// Set one paragraph attribute; Ok(false) for keys that aren't paragraph attributes. +pub fn apply_para_attr(p: &mut ParaStyle, key: &str, v: &J) -> Result { + match key { + "justify" => { + p.justify = v.as_str().and_then(Justify::parse).ok_or("justify: expected left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll")? + } + "indentLeft" => p.indent_left = num(key, v)?, + "indentRight" => p.indent_right = num(key, v)?, + "indentFirst" => p.indent_first = num(key, v)?, + "spaceBefore" => p.space_before = num(key, v)?, + "spaceAfter" => p.space_after = num(key, v)?, + "direction" => { + p.direction = match v.as_str().map(str::to_ascii_lowercase).as_deref() { + Some("ltr" | "lefttoright" | "left-to-right") => Direction::Ltr, + Some("rtl" | "righttoleft" | "right-to-left") => Direction::Rtl, + _ => return Err("direction: expected ltr|rtl".into()), + } + } + "composer" => { + p.composer = match v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")).as_deref() { + Some("everyline" | "every") => Composer::EveryLine, + Some("singleline" | "single") => Composer::SingleLine, + _ => return Err("composer: expected everyLine|singleLine".into()), + } + } + "hangingPunctuation" => p.hanging_punctuation = flag(key, v)?, + _ => return Ok(false), + } + Ok(true) +} + +/// A character style as `layer.setText` keys. +pub fn char_style_json(s: &CharStyle) -> J { + let c = |c: [f32; 4]| J::from(vec![c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64]); + serde_json::json!({ + "font": s.font, "style": s.style, "size": s.size, "fill": c(s.fill), "stroke": c(s.stroke), + "strokeWidth": s.stroke_width, "applyFill": s.apply_fill, "applyStroke": s.apply_stroke, + "tracking": s.tracking, "leading": s.leading.map_or(J::from("auto"), J::from), "baselineShift": s.baseline_shift, + "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, + "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), + "ligatures": s.ligatures, + }) +} + +/// Paragraph settings as `layer.setText` keys. +pub fn para_style_json(p: &ParaStyle) -> J { + serde_json::json!({ + "justify": p.justify.key(), "indentLeft": p.indent_left, "indentRight": p.indent_right, "indentFirst": p.indent_first, + "spaceBefore": p.space_before, "spaceAfter": p.space_after, + "direction": if p.direction == Direction::Rtl { "rtl" } else { "ltr" }, + "composer": if p.composer == Composer::EveryLine { "everyLine" } else { "singleLine" }, + "hangingPunctuation": p.hanging_punctuation, + }) +} + +/// Word boundaries for caret movement: the start of the word left of `ci`. +pub fn word_left(text: &str, ci: usize) -> usize { + let chars: Vec = text.chars().collect(); + let mut i = ci.min(chars.len()); + while i > 0 && !chars[i - 1].is_alphanumeric() { + i -= 1; + } + while i > 0 && chars[i - 1].is_alphanumeric() { + i -= 1; + } + i +} + +/// The end of the word right of `ci`. +pub fn word_right(text: &str, ci: usize) -> usize { + let chars: Vec = text.chars().collect(); + let mut i = ci.min(chars.len()); + while i < chars.len() && !chars[i].is_alphanumeric() { + i += 1; + } + while i < chars.len() && chars[i].is_alphanumeric() { + i += 1; + } + i +} + +/// The word (or run of spaces / punctuation) containing character `ci`, for double-click. +pub fn word_at(text: &str, ci: usize) -> Range { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0..0; + } + let i = ci.min(chars.len() - 1); + let class = |c: char| { + if c.is_alphanumeric() { + 0 + } else if c == '\n' { + 2 + } else if c.is_whitespace() { + 1 + } else { + 3 + } + }; + let k = class(chars[i]); + let (mut a, mut b) = (i, i + 1); + while a > 0 && class(chars[a - 1]) == k && k != 2 { + a -= 1; + } + while b < chars.len() && class(chars[b]) == k && k != 2 { + b += 1; + } + a..b +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sizes(d: &TextDoc) -> Vec<(usize, f64)> { + d.runs().iter().map(|r| (r.len, r.style.size)).collect() + } + + #[test] + fn style_runs_split_and_merge() { + let mut d = TextDoc::plain("Hello world"); + assert!(d.is_uniform()); + d.apply_style(6..11, |s| s.size = 40.0); + assert_eq!(sizes(&d), vec![(6, 72.0), (5, 40.0)]); + d.apply_style(2..8, |s| s.fill = [1.0, 0.0, 0.0, 1.0]); + assert_eq!(d.runs().len(), 4); + assert_eq!(d.style_at(7).fill, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(d.style_at(7).size, 40.0); + // Undo the colour: neighbours merge again. + d.apply_style(0..11, |s| s.fill = [1.0; 4]); + assert_eq!(sizes(&d), vec![(6, 72.0), (5, 40.0)]); + d.apply_style(0..11, |s| s.size = 10.0); + assert!(d.is_uniform()); + assert_eq!(d.size, 10.0); + } + + #[test] + fn edits_keep_runs_aligned() { + let mut d = TextDoc::plain("abcdef"); + d.apply_style(2..4, |s| s.size = 20.0); + // Typing after "cd" continues its style. + d.replace_range(4..4, "XY", None); + assert_eq!(d.text, "abcdXYef"); + assert_eq!(sizes(&d), vec![(2, 72.0), (4, 20.0), (2, 72.0)]); + // Typing at the very start takes the first character's style. + d.replace_range(0..0, "_", None); + assert_eq!(sizes(&d), vec![(3, 72.0), (4, 20.0), (2, 72.0)]); + // Deleting the whole styled span merges the outer runs. + d.replace_range(3..7, "", None); + assert_eq!(d.text, "_abef"); + assert!(d.is_uniform()); + // Replacing a selection uses the first selected character's style. + d.apply_style(1..2, |s| s.size = 5.0); + d.replace_range(1..3, "Q", None); + assert_eq!(d.text, "_Qef"); + assert_eq!(sizes(&d), vec![(1, 72.0), (1, 5.0), (2, 72.0)]); + // Deleting everything keeps the style of what was there. + d.replace_range(0..4, "", None); + assert_eq!(d.text, ""); + assert!(d.is_uniform()); + assert_eq!(d.size, 72.0); + } + + #[test] + fn paragraphs_follow_edits() { + let mut d = TextDoc::plain("one\ntwo\nthree"); + assert_eq!(d.para_count(), 3); + d.apply_para(4..5, |p| p.indent_left = 30.0); + assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); + // Enter inside "two" splits it: both halves keep the indent. + d.replace_range(5..5, "\n", None); + assert_eq!(d.text, "one\nt\nwo\nthree"); + assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 30.0, 0.0]); + // Joining the first two paragraphs keeps the first's settings. + d.replace_range(3..4, "", None); + assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); + assert_eq!(d.para_ranges(), vec![0..4, 5..7, 8..13]); + // Space before only on paragraph 3 via a range ending in it. + d.apply_para(9..10, |p| p.space_before = 12.0); + assert_eq!(d.para(2).space_before, 12.0); + assert_eq!(d.para(1).space_before, 0.0); + } + + #[test] + fn single_style_serde_is_unchanged_and_old_docs_load() { + let d = TextDoc::plain("Hi"); + let j = serde_json::to_value(&d).unwrap(); + assert!(j.get("runs").is_none() && j.get("paragraphs").is_none()); + // A document saved before style runs existed (no new fields). + let old = r#"{"text":"Old","font":"Inter","style":"Bold","size":50.0,"fill":[1,0,0,1],"stroke":[0,0,0,1], + "stroke_width":0.0,"apply_fill":true,"apply_stroke":false,"stroke_over_fill":true,"tracking":10.0, + "leading":null,"justify":"Center","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":[300.0,200.0],"box_pos":[0.0,0.0]}"#; + let d: TextDoc = serde_json::from_str(old).unwrap(); + assert!(d.is_uniform()); + assert_eq!(d.style_at(2).size, 50.0); + assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking"); + assert!(d.ligatures); + assert_eq!(d.kerning, Kerning::Metrics); + // Round trip with runs. + let mut m = d.clone(); + m.apply_style(0..1, |s| s.kerning = Kerning::Manual(-40.0)); + let back: TextDoc = serde_json::from_value(serde_json::to_value(&m).unwrap()).unwrap(); + assert_eq!(back, m); + assert_eq!(back.style_at(0).kerning, Kerning::Manual(-40.0)); + } + + #[test] + fn slice_and_insert_doc_carry_formatting() { + let mut d = TextDoc::plain("red blue"); + d.apply_style(0..3, |s| s.fill = [1.0, 0.0, 0.0, 1.0]); + let frag = d.slice(2..6); + assert_eq!(frag.text, "d bl"); + assert_eq!(frag.runs().len(), 2); + let mut e = TextDoc::plain("xx"); + e.insert_doc(1..1, &frag); + assert_eq!(e.text, "xd blx"); + assert_eq!(e.style_at(1).fill, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(e.style_at(2).fill, [1.0; 4]); + } + + #[test] + fn attrs_by_key() { + let mut d = TextDoc::plain("abc\ndef"); + assert!(d.set_attr("size", &J::from(20), Some(0..2)).unwrap()); + assert!(d.set_attr("kerning", &J::from("optical"), None).unwrap()); + assert!(d.set_attr("justify", &J::from("justifyAll"), Some(5..5)).unwrap()); + assert!(d.set_attr("superscript", &J::from(true), Some(2..3)).unwrap()); + assert!(!d.set_attr("nope", &J::from(1), None).unwrap()); + assert!(d.set_attr("size", &J::from("big"), None).is_err()); + assert_eq!(d.style_at(1).size, 20.0); + assert_eq!(d.style_at(2).baseline, BaselineOption::Superscript); + assert_eq!(d.style_at(6).kerning, Kerning::Optical); + assert_eq!(d.para(1).justify, Justify::JustifyAll); + assert_eq!(d.para(0).justify, Justify::Left); + let j = char_style_json(&d.style_at(0)); + let mut s = CharStyle::default(); + for (k, v) in j.as_object().unwrap() { + assert!(apply_char_attr(&mut s, k, v).unwrap(), "{k}"); + } + assert_eq!(s, d.style_at(0)); + } + + #[test] + fn word_boundaries() { + let t = "hello, big world"; + assert_eq!(word_right(t, 0), 5); + assert_eq!(word_right(t, 5), 10); + assert_eq!(word_left(t, 16), 11); + assert_eq!(word_left(t, 11), 7); + assert_eq!(word_at(t, 8), 7..10); + assert_eq!(word_at(t, 6), 6..7); + } +} diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index a5c685d4..abf112fc 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -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, - 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::(j.clone()).ok().map(|t| Value::Text(Box::new(t))) + serde_json::from_value::(j.clone()).ok().map(|mut t| { + t.normalize(); + Value::Text(Box::new(t)) + }) } } Value::Layer(_) => Some(Value::Layer(j.as_u64())), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165ea298..37b0f775 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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 { let mut subs: Vec> = 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 = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect(); - let mut chars = src.clone(); + let mut chars: Vec = 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 = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect(); let x2: Vec = 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 { 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 { } } - 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 { 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 { 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 { } 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 { 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); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 75db90b2..824b7373 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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, /// 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, } 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, /// Base direction: None = from the first strong character. pub rtl: Option, + /// 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(&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(&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, /// 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 { +/// 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 { 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> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect(); + let gaps: Vec> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect(); + let mut sorted: Vec = 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, glyphs: Vec, 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, + 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, 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 { - let pm = fonts::face(primary.face).metrics(style.size); +fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option) -> Vec { + 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 { + 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 = 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| -> 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, 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| -> 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> = Vec::new(); - match para.width { + match width { None => ranges.push(0..text.len()), - Some(maxw) => { - let mut start = 0usize; - let mut last_ok: Option = 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 = 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 = 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 = 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 = 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 = (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 = 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, 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 = 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)>> { 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_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, TextStyle)], paras: &[ParagraphStyle]) -> Arc { 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= 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, 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 = 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); + } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ab77f44f..1bd93920 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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> { +pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { static C: OnceLock>>>> = 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> { 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, + /// The paragraph layout (layout space) and its map into layer space. + pub layout: Arc, + pub to_layer: Affine, + /// Byte offset of every character, plus the text length. + pub byte_of_char: Vec, + 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) -> 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 = 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 = 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) -> (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 = 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() }; From 744ccd6b4805a17d47f05556cd8be24ddb12e24c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:34:13 -0400 Subject: [PATCH 4/4] M9.10: on-canvas text editing Type tool point/box text creation, double-click editing, caret and selection in the viewer (viewer_text.rs), keyboard/IME/clipboard, box handles; engine text.edit/endEdit/setSelection/moveCaret/insert/delete, layer.setText range, caret insertion style, Paste Text and Match Formatting / Paste Text Formatting Only (stubs removed); Character/Paragraph/Properties panels act on the selection. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/src/commands/edit.rs | 19 + crates/engine/src/commands/layer.rs | 172 ++++---- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/stubs.rs | 3 - crates/engine/src/commands/text_edit.rs | 540 +++++++++++++++++++++++ crates/engine/src/lib.rs | 10 + crates/engine/src/tests_text_edit.rs | 204 +++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/panels/text_panels.rs | 351 +++++++++++---- crates/ui-egui/src/panels/viewer.rs | 60 +-- crates/ui-egui/src/panels/viewer_text.rs | 522 ++++++++++++++++++++++ crates/ui-egui/tests/ui_text_edit.rs | 192 ++++++++ docs/agents.md | 23 + docs/parity.md | 6 +- 16 files changed, 1890 insertions(+), 225 deletions(-) create mode 100644 crates/engine/src/commands/text_edit.rs create mode 100644 crates/engine/src/tests_text_edit.rs create mode 100644 crates/ui-egui/src/panels/viewer_text.rs create mode 100644 crates/ui-egui/tests/ui_text_edit.rs diff --git a/Cargo.lock b/Cargo.lock index 68e5616f..3a8b2998 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,7 @@ dependencies = [ "effectcraft-render", "effectcraft-text", "effectcraft-time", + "serde_json", ] [[package]] diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 5ce1cec1..b68144bb 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -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 { } fn select_all(s: &mut Session, _: &Value) -> Result { + 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 { } fn delete(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { } fn cut(s: &mut Session, p: &Value) -> Result { + 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 { } fn paste(s: &mut Session, p: &Value) -> Result { + 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); } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09a50660..0fb86385 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -67,92 +67,65 @@ fn new_adjustment(s: &mut Session, p: &Value) -> Result { 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>) -> 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> { + 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 { 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 { { 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})) } @@ -801,20 +779,40 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { 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(); } @@ -922,7 +920,15 @@ fn with_layer(p: &Value, lid: LayerId) -> Value { pub fn specs() -> Vec { 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!( @@ -996,7 +1002,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index d0fcfd21..f61a2ba2 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -27,6 +27,7 @@ mod settings; mod stubs; mod styles; mod text_anim; +pub mod text_edit; mod three_d; mod time; mod track; @@ -92,6 +93,7 @@ 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(anim::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index c04f1f98..7c4b5a70 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -26,9 +26,6 @@ pub fn specs() -> Vec { 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"], "{}"), diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs new file mode 100644 index 00000000..28048389 --- /dev/null +++ b/crates/engine/src/commands/text_edit.rs @@ -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, + /// 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, +} + +impl TextEdit { + pub fn range(&self) -> std::ops::Range { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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)> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>)> = 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 = 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 { + 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 { + s.state.text_edit.as_ref().map(|e| e.layer) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b5c5f43..a5bcd29f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,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 +179,12 @@ 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, + /// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only. + #[serde(skip)] + pub text_clipboard: Option, } fn one_view() -> u8 { @@ -394,6 +401,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 { @@ -532,6 +540,8 @@ mod tests_styles; #[cfg(test)] mod tests_text; #[cfg(test)] +mod tests_text_edit; +#[cfg(test)] mod tests_timeline; #[cfg(test)] mod tests_track; diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs new file mode 100644 index 00000000..2476727f --- /dev/null +++ b/crates/engine/src/tests_text_edit.rs @@ -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); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index fffe91bc..56d05445 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -23,6 +23,7 @@ pub mod text_panels; pub mod timeline; pub mod tracker; pub mod viewer; +pub mod viewer_text; pub mod waveform; use effectcraft_engine::Session; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index cb963580..fbd4e5e9 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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; } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ab70a816..edb1132f 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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 { 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 = ["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::().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 = 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). diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a02c9b3a..65dd6516 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -733,7 +733,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let vertex_at = |pos: Pos2, tangents: bool| -> Option { 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); @@ -1014,6 +1017,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,12 +1030,7 @@ 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 => {} t if t.puppet_kind().is_some() => { if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0) @@ -1059,22 +1058,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if resp.double_clicked() + && !text_owns && app.ui.tool != Tool::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); @@ -1299,40 +1293,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(); diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs new file mode 100644 index 00000000..65f9da04 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -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 { + 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 { + 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 { + 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 = 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::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_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::>(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::>(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::(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, 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 = 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 = 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 = 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 }); + }); +} diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs new file mode 100644 index 00000000..2c943740 --- /dev/null +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -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"); +} diff --git a/docs/agents.md b/docs/agents.md index 1a82244c..fd2cc8ea 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/parity.md b/docs/parity.md index df6b963a..13afaa2a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -66,7 +66,7 @@ rendering, on-canvas text editing) are where the conservative figure is most lik | Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes | | 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 | +| 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 | @@ -87,7 +87,7 @@ 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. 4. A GPU (wgpu) compositor, then GPU effects. @@ -98,7 +98,7 @@ Mocha and Cineware are third-party and not counted. 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.