M12.2: merge main (ROI, viewer tools); gate GPU display textures
Region-of-interest frames render on the CPU path; the viewer keeps main's frame drawing (Show Channel, exposure, ROI) and reads GPU-composited frames back. Keeping frames as native egui-wgpu textures stays implemented behind RenderSource::gpu_display (off). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a438974758
|
|
@ -1247,6 +1247,7 @@ dependencies = [
|
|||
"effectcraft-render",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.text_edit.is_some() {
|
||||
// Text editing pastes the system clipboard's text (passed as `text`) or copied text.
|
||||
return Ok(());
|
||||
}
|
||||
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() {
|
||||
Err("the clipboard is empty".into())
|
||||
} else {
|
||||
|
|
@ -49,6 +53,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() {
|
||||
return s.execute("text.setSelection", json!({"select": "all"}));
|
||||
}
|
||||
if let Some(c) = s.active_comp() {
|
||||
s.state.selected_layers = c.layers.iter().map(|l| l.id).collect();
|
||||
}
|
||||
|
|
@ -103,6 +110,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return s.execute("text.delete", json!({}));
|
||||
}
|
||||
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("keys.delete", json!({}));
|
||||
|
|
@ -144,6 +154,9 @@ fn clear_clipboards(s: &mut Session) {
|
|||
|
||||
/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers.
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return super::text_edit::copy(s);
|
||||
}
|
||||
// Keyframes selected → copy keys (pasted at the CTI).
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
|
|
@ -322,6 +335,9 @@ fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return super::text_edit::cut(s);
|
||||
}
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.execute("keys.copy", json!({}))?;
|
||||
return s.execute("keys.delete", json!({}));
|
||||
|
|
@ -331,6 +347,9 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.state.text_edit.is_some() {
|
||||
return super::text_edit::paste(s, p);
|
||||
}
|
||||
if let Some(clip) = s.state.link_clipboard.clone() {
|
||||
return paste_links(s, clip);
|
||||
}
|
||||
|
|
@ -502,6 +521,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("all").to_string();
|
||||
if matches!(what.as_str(), "all" | "snapshot") {
|
||||
s.snapshot = None;
|
||||
s.state.viewer.show_snapshot = false;
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what}))
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box
|
||||
//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and
|
||||
//! spatial Bezier tangents dragged on a motion path.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
||||
}
|
||||
|
||||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
let voff = f_p(p, "valueOffset").unwrap_or(0.0);
|
||||
let va = f_p(p, "valueAnchor").unwrap_or(0.0);
|
||||
// `dim` (one dimension) or `dims` (several); none = every dimension.
|
||||
let dims: Option<Vec<usize>> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) {
|
||||
(Some(d), _) => Some(vec![d as usize]),
|
||||
(None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()),
|
||||
_ => None,
|
||||
};
|
||||
if !ts.is_finite() || ts <= 0.0 {
|
||||
return Err(bad("keys.transform", "`timeScale` must be positive"));
|
||||
}
|
||||
// Default time anchor: the earliest selected key (comp time).
|
||||
let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO);
|
||||
let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first);
|
||||
let map_t = |ct: Tick| -> Tick {
|
||||
let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff;
|
||||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
let d = ct - start;
|
||||
if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) }
|
||||
};
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut moved, mut kept): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
for k in &mut moved {
|
||||
k.time = to_layer(map_t(to_comp(k.time)));
|
||||
if touch_values {
|
||||
let mut c = k.value.components();
|
||||
for (d, v) in c.iter_mut().enumerate() {
|
||||
if dims.as_ref().is_none_or(|x| x.contains(&d)) {
|
||||
*v = va + (*v - va) * vs + voff;
|
||||
}
|
||||
}
|
||||
k.value = k.value.with_components(&c);
|
||||
}
|
||||
}
|
||||
// Transformed keys win over unselected keys at the same time.
|
||||
kept.retain(|k| !moved.iter().any(|m| m.time == k.time));
|
||||
moved.sort_by_key(|k| k.time);
|
||||
moved.dedup_by_key(|k| k.time);
|
||||
for k in &moved {
|
||||
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
}
|
||||
kept.extend(moved);
|
||||
kept.sort_by_key(|k| k.time);
|
||||
pr.keys = kept;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
st.selected_keys = new_sel;
|
||||
Ok(json!({"keys": st.selected_keys.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key
|
||||
/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops
|
||||
/// being auto Bezier.
|
||||
fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?;
|
||||
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?);
|
||||
let (tin, tout) = (v3(p.get("in")), v3(p.get("out")));
|
||||
if tin.is_none() && tout.is_none() {
|
||||
return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]"));
|
||||
}
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
}
|
||||
let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i));
|
||||
let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?;
|
||||
let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i);
|
||||
let k = &mut pr.keys[i];
|
||||
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
|
||||
let (ni, no) = match (tin, tout) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(Some(a), None) => (a, if brk { cur_out } else { neg(a) }),
|
||||
(None, Some(b)) => (if brk { cur_in } else { neg(b) }, b),
|
||||
(None, None) => (cur_in, cur_out),
|
||||
};
|
||||
k.spatial_auto = false;
|
||||
k.spatial_continuous = !brk && tin.is_some() != tout.is_some();
|
||||
k.spatial_in = ni;
|
||||
k.spatial_out = no;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, true);
|
||||
Ok(json!({"in": ni, "out": no}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"keys.transform",
|
||||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
cmd!(
|
||||
"keys.setSpatialTangents",
|
||||
"Edit Spatial Tangents",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}",
|
||||
has_comp,
|
||||
spatial_tangents
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -67,92 +67,65 @@ fn new_adjustment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
new_solid_like(s, p, true)
|
||||
}
|
||||
|
||||
fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
|
||||
let mut d = base;
|
||||
/// Parameters of `layer.setText` / `layer.newText` that aren't text attributes.
|
||||
const NOT_ATTRS: &[&str] = &["layer", "layers", "comp", "merge", "text", "range", "position", "edit"];
|
||||
|
||||
/// Apply `layer.setText` keys to `d`: `text` replaces characters `r` (or everything), then each
|
||||
/// character / paragraph / document attribute applies to `r` (or everything).
|
||||
fn apply_text_params(cmd: &str, p: &Value, d: &mut TextDoc, r: Option<std::ops::Range<usize>>) -> Result<()> {
|
||||
let mut r = r;
|
||||
if let Some(t) = str_p(p, "text") {
|
||||
d.text = t.to_string();
|
||||
match r.clone() {
|
||||
Some(rr) => {
|
||||
d.replace_range(rr.clone(), t, None);
|
||||
r = Some(rr.start..rr.start + t.chars().count());
|
||||
}
|
||||
None => d.set_text(t),
|
||||
}
|
||||
}
|
||||
if let Some(v) = f_p(p, "size") {
|
||||
d.size = v.max(1.0);
|
||||
let Some(obj) = p.as_object() else { return Ok(()) };
|
||||
for (k, v) in obj {
|
||||
if NOT_ATTRS.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !d.set_attr(k, v, r.clone()).map_err(|e| bad(cmd, e))? {
|
||||
return Err(bad(cmd, format!("unknown text attribute {k}")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = str_p(p, "font") {
|
||||
d.font = v.to_string();
|
||||
}
|
||||
if let Some(v) = str_p(p, "style") {
|
||||
d.style = v.to_string();
|
||||
}
|
||||
if let Some(c) = color_p(p, "fill") {
|
||||
d.fill = [c[0], c[1], c[2], 1.0];
|
||||
}
|
||||
if let Some(c) = color_p(p, "stroke") {
|
||||
d.stroke = [c[0], c[1], c[2], 1.0];
|
||||
d.apply_stroke = true;
|
||||
}
|
||||
if let Some(v) = f_p(p, "strokeWidth") {
|
||||
d.stroke_width = v.max(0.0);
|
||||
d.apply_stroke = v > 0.0;
|
||||
}
|
||||
if let Some(v) = f_p(p, "tracking") {
|
||||
d.tracking = v;
|
||||
}
|
||||
match p.get("leading") {
|
||||
Some(Value::Number(n)) => d.leading = n.as_f64(),
|
||||
// "auto" or null: Auto Leading (120% of the font size).
|
||||
Some(Value::String(_) | Value::Null) => d.leading = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = b_p(p, "applyFill") {
|
||||
d.apply_fill = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "applyStroke") {
|
||||
d.apply_stroke = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "allCaps") {
|
||||
d.all_caps = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "smallCaps") {
|
||||
d.small_caps = v;
|
||||
}
|
||||
if let Some(v) = f_p(p, "hScale") {
|
||||
d.h_scale = v.clamp(1.0, 1000.0);
|
||||
}
|
||||
if let Some(v) = f_p(p, "vScale") {
|
||||
d.v_scale = v.clamp(1.0, 1000.0);
|
||||
}
|
||||
if let Some(v) = f_p(p, "baselineShift") {
|
||||
d.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "strokeOverFill") {
|
||||
d.stroke_over_fill = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "fauxBold") {
|
||||
d.faux_bold = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "fauxItalic") {
|
||||
d.faux_italic = v;
|
||||
}
|
||||
if let Some(j) = str_p(p, "justify") {
|
||||
// AE's seven Paragraph alignment buttons.
|
||||
d.justify = match j.to_ascii_lowercase().as_str() {
|
||||
"center" | "centre" => Justify::Center,
|
||||
"right" => Justify::Right,
|
||||
"justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft,
|
||||
"justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter,
|
||||
"justifyright" | "justifylastright" => Justify::JustifyLastRight,
|
||||
"justifyall" => Justify::JustifyAll,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
}
|
||||
d
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
let a = p.get("range")?.as_array()?;
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
|
||||
let (x, y) = (g(0)?, g(1).or(g(0))?);
|
||||
Some(x.min(y)..x.max(y))
|
||||
}
|
||||
|
||||
fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let doc = text_doc_from(p, TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() });
|
||||
let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
let mut doc = TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() };
|
||||
// A paragraph box given in comp space: the layer sits at its centre.
|
||||
let bx = p.get("box").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)));
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("box");
|
||||
}
|
||||
apply_text_params("layer.newText", &q, &mut doc, None)?;
|
||||
let mut pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
if let Some([x, y, w, h]) = bx {
|
||||
let (w, h) = (w.abs().max(1.0), h.abs().max(1.0));
|
||||
doc.box_size = Some([w, h]);
|
||||
doc.box_pos = [-w / 2.0, -h / 2.0];
|
||||
pos = Some([x + w / 2.0, y + h / 2.0]);
|
||||
if p.get("justify").is_none() {
|
||||
doc.set_attr("justify", &json!("left"), None).map_err(|e| bad("layer.newText", e))?;
|
||||
}
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = doc.text.lines().next().unwrap_or("Text").chars().take(40).collect();
|
||||
let name: String = doc.text.lines().next().unwrap_or("").chars().take(40).collect();
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc.clone()));
|
||||
|
|
@ -162,7 +135,12 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
{
|
||||
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
|
||||
}
|
||||
insert_layer(proj, st, cid, l)
|
||||
let n = doc.char_len();
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
if edit {
|
||||
st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() });
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
|
@ -852,20 +830,40 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.setText")?;
|
||||
let t = s.time();
|
||||
let range = text_range_p(p);
|
||||
// Character attributes with only a caret (no selected text) set the insertion style.
|
||||
if let Some(r) = range.clone().filter(|r| r.is_empty() && str_p(p, "text").is_none())
|
||||
&& s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid)
|
||||
&& let Some(doc) = super::text_edit::layer_doc(s, lid)
|
||||
&& doc.char_len() > 0
|
||||
&& let Some(obj) = p.as_object()
|
||||
{
|
||||
let e = s.state.text_edit.as_mut().expect("checked");
|
||||
let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start));
|
||||
let mut any = false;
|
||||
for (k, v) in obj {
|
||||
any |= effectcraft_keyframe::text_doc::apply_char_attr(&mut st, k, v).map_err(|m| bad("layer.setText", m))?;
|
||||
}
|
||||
if any {
|
||||
e.pending = Some(st);
|
||||
}
|
||||
s.bump();
|
||||
}
|
||||
s.edit("Edit Text", merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?;
|
||||
let cur = match pr.value_at(lt) {
|
||||
let mut doc = match pr.value_at(lt) {
|
||||
KV::Text(d) => *d,
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let doc = text_doc_from(p, cur);
|
||||
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
|
||||
if let Some(first) = doc.text.lines().next()
|
||||
&& str_p(p, "text").is_some()
|
||||
&& !first.is_empty()
|
||||
&& l.name.starts_with("Text")
|
||||
&& (l.name.starts_with("Text") || l.name.is_empty())
|
||||
{
|
||||
l.name = first.chars().take(40).collect();
|
||||
}
|
||||
|
|
@ -973,7 +971,15 @@ fn with_layer(p: &Value, lid: LayerId) -> Value {
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text),
|
||||
cmd!(
|
||||
"layer.newText",
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
|
||||
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
|
||||
cmd!(
|
||||
|
|
@ -1058,7 +1064,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, smallCaps?, fauxBold?, fauxItalic?, hScale? %, vScale? %, baselineShift? px, strokeOverFill?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,19 +20,38 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
|||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = match key {
|
||||
/// Index of a mask group by uid, 1-based index or name.
|
||||
fn mask_index(masks: &PropGroup, key: &Value) -> Option<usize> {
|
||||
match key {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))?
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))
|
||||
}
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s)?,
|
||||
_ => return None,
|
||||
};
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = mask_index(masks, key)?;
|
||||
masks.children.get_mut(i)?.as_group_mut()
|
||||
}
|
||||
|
||||
/// Whether a group holds an editable Bezier path (a mask, or a shape layer's Path item).
|
||||
pub(crate) fn is_path_group(g: &PropGroup) -> bool {
|
||||
matches!(g.get("path").map(|p| &p.value), Some(KV::Path(_))) && (matches!(g.kind, effectcraft_project::GroupKind::Mask { .. }) || g.match_id == "path")
|
||||
}
|
||||
|
||||
/// A mask (uid, 1-based index or name) or, by uid, a shape layer's Path item.
|
||||
pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
if let Some(i) = props.sub("masks").and_then(|m| mask_index(m, key)) {
|
||||
return props.sub_mut("masks")?.children.get_mut(i)?.as_group_mut();
|
||||
}
|
||||
let uid = key.as_u64()?;
|
||||
props.find_group_mut(uid).filter(|g| is_path_group(g))
|
||||
}
|
||||
|
||||
/// Edit the mask path of (layer, mask) at the current time.
|
||||
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
|
||||
let (cid, lid) = layer_p(s, p, label)?;
|
||||
|
|
@ -41,8 +60,7 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
|
|||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
|
||||
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
|
||||
let g = path_group(&mut l.props, &key).ok_or_else(|| bad(label, "no such mask or shape path"))?;
|
||||
let uid = g.uid;
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
|
|
@ -186,7 +204,7 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
|
|||
for ((l, m), idx) in &groups {
|
||||
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
|
||||
let lt = layer.layer_time(t);
|
||||
let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue };
|
||||
let Some(g) = layer.props.find_group_mut(*m).filter(|g| is_path_group(g)) else { continue };
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
|
|
@ -233,6 +251,93 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(n)
|
||||
}
|
||||
|
||||
/// Convert Vertex tool: a corner vertex becomes smooth (tangents along its neighbours, a sixth
|
||||
/// of their distance each way), a smooth one becomes a corner (no tangents). `smooth` forces one.
|
||||
fn convert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.convertVertex", "missing `index`"))? as usize;
|
||||
let force = b_p(p, "smooth");
|
||||
let (_, smooth) = edit_path(s, p, "Convert Vertex", |sp| {
|
||||
let n = sp.vertices.len();
|
||||
if i >= n {
|
||||
return Err(bad("mask.convertVertex", "no such vertex"));
|
||||
}
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let is_smooth = sp.in_tangents[i] != [0.0; 2] || sp.out_tangents[i] != [0.0; 2];
|
||||
let make_smooth = force.unwrap_or(!is_smooth);
|
||||
if make_smooth {
|
||||
let prev = if i > 0 {
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if i + 1 < n {
|
||||
Some(i + 1)
|
||||
} else if sp.closed {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let v = sp.vertices[i];
|
||||
let a = prev.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let b = next.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let mut d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0];
|
||||
if d == [0.0, 0.0] {
|
||||
d = [10.0, 0.0];
|
||||
}
|
||||
sp.out_tangents[i] = d;
|
||||
sp.in_tangents[i] = [-d[0], -d[1]];
|
||||
} else {
|
||||
sp.in_tangents[i] = [0.0; 2];
|
||||
sp.out_tangents[i] = [0.0; 2];
|
||||
}
|
||||
Ok(make_smooth)
|
||||
})?;
|
||||
Ok(json!({"smooth": smooth}))
|
||||
}
|
||||
|
||||
/// Split cubic segment `i` (vertex i → i+1) at parameter `t`; the path keeps its shape.
|
||||
pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<usize> {
|
||||
let n = sp.vertices.len();
|
||||
let j = if seg + 1 < n {
|
||||
seg + 1
|
||||
} else if sp.closed && seg + 1 == n {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let p0 = sp.vertices[seg];
|
||||
let p3 = sp.vertices[j];
|
||||
let p1 = [p0[0] + sp.out_tangents[seg][0], p0[1] + sp.out_tangents[seg][1]];
|
||||
let p2 = [p3[0] + sp.in_tangents[j][0], p3[1] + sp.in_tangents[j][1]];
|
||||
let lerp = |a: [f64; 2], b: [f64; 2]| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
let (a, b, c) = (lerp(p0, p1), lerp(p1, p2), lerp(p2, p3));
|
||||
let (d, e) = (lerp(a, b), lerp(b, c));
|
||||
let m = lerp(d, e);
|
||||
sp.out_tangents[seg] = [a[0] - p0[0], a[1] - p0[1]];
|
||||
sp.in_tangents[j] = [c[0] - p3[0], c[1] - p3[1]];
|
||||
let at = seg + 1;
|
||||
sp.vertices.insert(at, m);
|
||||
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
|
||||
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
|
||||
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg =
|
||||
p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize;
|
||||
let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
let (_, lid) = layer_p(s, p, "mask.insertVertex")?;
|
||||
let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
Ok(json!(i))
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.remove")?;
|
||||
let key = p.get("mask").cloned();
|
||||
|
|
@ -263,6 +368,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
|
||||
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
|
||||
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ mod file;
|
|||
mod file_more;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
mod layer_time;
|
||||
|
|
@ -27,10 +28,14 @@ mod settings;
|
|||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
pub mod text_edit;
|
||||
mod three_d;
|
||||
mod time;
|
||||
mod track;
|
||||
mod view;
|
||||
pub mod viewer_cmds;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -92,6 +97,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());
|
||||
|
|
@ -106,6 +112,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(layer_menu::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(comp_more::specs());
|
||||
|
|
|
|||
|
|
@ -63,6 +63,13 @@ fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
|
|||
v.dedup();
|
||||
return Ok((cid, v));
|
||||
}
|
||||
// A shape layer's Path item by uid.
|
||||
if let Some(uid) = p.get("mask").and_then(Value::as_u64) {
|
||||
let (cid, lid) = layer_p(s, p, "path")?;
|
||||
if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") {
|
||||
return Ok((cid, vec![(lid, uid)]));
|
||||
}
|
||||
}
|
||||
target_masks(s, p)
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +237,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
|
||||
let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue };
|
||||
let roto = is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
// Text editing in the viewer.
|
||||
stub!("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", ["Edit"], "{}"),
|
||||
stub!("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", ["Edit"], "{}"),
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
|
|
@ -56,7 +53,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"),
|
||||
// Color management.
|
||||
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
|
||||
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,540 @@
|
|||
//! On-canvas text editing (Type tool): the edit session (layer, selection, insertion style), caret
|
||||
//! movement, typing, deleting, the text clipboard and Paste Text and Match Formatting / Paste Text
|
||||
//! Formatting Only. Every change to the text goes through an undoable edit of Source Text at the
|
||||
//! CTI; the selection lives in [`EditorState::text_edit`] so agents and the UI share it.
|
||||
//!
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{LayerId, LayerSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
|
||||
use crate::{EditorState, EngineError, Result, Session, cmd};
|
||||
|
||||
/// An on-canvas text editing session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextEdit {
|
||||
pub layer: LayerId,
|
||||
pub anchor: usize,
|
||||
pub caret: usize,
|
||||
/// Character panel changes made with only a caret: the style the next typed text takes.
|
||||
#[serde(default)]
|
||||
pub pending: Option<CharStyle>,
|
||||
/// The layer was made by a Type tool click; leaving it empty deletes it.
|
||||
#[serde(default)]
|
||||
pub created: bool,
|
||||
/// Line position kept by repeated up / down moves (layout space).
|
||||
#[serde(default)]
|
||||
pub goal_x: Option<f64>,
|
||||
}
|
||||
|
||||
impl TextEdit {
|
||||
pub fn range(&self) -> std::ops::Range<usize> {
|
||||
self.anchor.min(self.caret)..self.anchor.max(self.caret)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.anchor == self.caret
|
||||
}
|
||||
}
|
||||
|
||||
/// The Source Text document of a layer in the active comp at the CTI (keyframed value).
|
||||
pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
|
||||
let l = s.active_comp()?.layer(lid)?;
|
||||
match l.props.prop("text/sourceText")?.value_at(l.layer_time(s.time())) {
|
||||
KV::Text(d) => Some(*d),
|
||||
KV::Str(t) => Some(TextDoc::plain(&t)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let lid = match p.get("layer") {
|
||||
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
|
||||
None => match &s.state.text_edit {
|
||||
Some(e) => e.layer,
|
||||
None => s
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text)))
|
||||
.ok_or_else(|| bad(cmd, "select a text layer"))?,
|
||||
},
|
||||
};
|
||||
match comp.layer(lid) {
|
||||
Some(l) if matches!(l.source, LayerSource::Text) => Ok(lid),
|
||||
_ => Err(bad(cmd, "not a text layer")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit session on `lid` (started with the caret at the end when there is none).
|
||||
fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
match &s.state.text_edit {
|
||||
Some(e) if e.layer == lid => e.clone(),
|
||||
_ => {
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(e.clone());
|
||||
e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_line_name(text: &str) -> String {
|
||||
text.lines().next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the
|
||||
/// session's selection.
|
||||
pub(crate) fn edit_doc(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
merge: Option<&str>,
|
||||
lid: LayerId,
|
||||
f: impl FnOnce(&mut TextDoc, &mut EditorState) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
|
||||
let mut doc = match pr.value_at(lt) {
|
||||
KV::Text(d) => *d,
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = first_line_name(&doc.text);
|
||||
f(&mut doc, st)?;
|
||||
let after = first_line_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
l.name = after;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
let a = p.get("range")?.as_array()?;
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
|
||||
let (x, y) = (g(0)?, g(1).or(g(0))?);
|
||||
Some(x.min(y)..x.max(y))
|
||||
}
|
||||
|
||||
fn state_json(s: &Session) -> Value {
|
||||
match &s.state.text_edit {
|
||||
Some(e) => json!({"layer": e.layer.0, "anchor": e.anchor, "caret": e.caret, "start": e.range().start, "end": e.range().end}),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.edit`: start editing a text layer.
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.edit")?;
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let (anchor, caret) = match p.get("select") {
|
||||
Some(Value::Array(a)) => {
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).map_or(n, |x| (x as usize).min(n));
|
||||
(g(0), g(1))
|
||||
}
|
||||
Some(Value::String(x)) if x == "none" => (n, n),
|
||||
_ => match p.get("caret").and_then(Value::as_u64) {
|
||||
Some(c) => ((c as usize).min(n), (c as usize).min(n)),
|
||||
None => (0, n),
|
||||
},
|
||||
};
|
||||
let created = b_p(p, "created").unwrap_or(false);
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
||||
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
||||
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
s.edit("Delete Empty Text Layer", None, |proj, st| {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
c.layers.retain(|l| l.id != e.layer);
|
||||
}
|
||||
st.selected_layers.retain(|l| *l != e.layer);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.bump();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn set_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.setSelection")?;
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let mut e = session_on(s, lid);
|
||||
let g = |k: &str| p.get(k).and_then(Value::as_u64).map(|x| (x as usize).min(n));
|
||||
let (a, c) = match (g("start").or(g("anchor")), g("end").or(g("caret"))) {
|
||||
(Some(a), Some(c)) => (a, c),
|
||||
(Some(a), None) | (None, Some(a)) => (a, a),
|
||||
(None, None) => match str_p(p, "select") {
|
||||
Some("all") => (0, n),
|
||||
_ => return Err(bad("text.setSelection", "give start/end (or anchor/caret), or select: \"all\"")),
|
||||
},
|
||||
};
|
||||
if (e.anchor, e.caret) != (a, c) {
|
||||
e.pending = None;
|
||||
}
|
||||
e.anchor = a;
|
||||
e.caret = c;
|
||||
e.goal_x = None;
|
||||
s.state.text_edit = Some(e);
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Caret targets for `text.moveCaret`.
|
||||
fn move_target(s: &Session, e: &TextEdit, doc: &TextDoc, to: &str) -> Result<(usize, Option<f64>)> {
|
||||
let n = doc.char_len();
|
||||
let (lo, hi) = (e.range().start, e.range().end);
|
||||
let collapse = !e.is_empty();
|
||||
let lay = || effectcraft_text::layout_doc(doc);
|
||||
Ok(match to {
|
||||
"left" if collapse => (lo, None),
|
||||
"right" if collapse => (hi, None),
|
||||
"left" => (e.caret.saturating_sub(1), None),
|
||||
"right" => ((e.caret + 1).min(n), None),
|
||||
"wordLeft" => (effectcraft_keyframe::text_doc::word_left(&doc.text, e.caret), None),
|
||||
"wordRight" => (effectcraft_keyframe::text_doc::word_right(&doc.text, e.caret), None),
|
||||
"lineStart" | "home" => (lay().line_span(e.caret).0, None),
|
||||
"lineEnd" => (lay().line_span(e.caret).1, None),
|
||||
"up" | "down" => {
|
||||
let (c, g) = lay().move_vertical(e.caret, if to == "up" { -1 } else { 1 }, e.goal_x);
|
||||
(c, Some(g))
|
||||
}
|
||||
"paraStart" | "paraUp" => {
|
||||
let r = &doc.para_ranges()[doc.para_of(e.caret)];
|
||||
let pi = doc.para_of(e.caret);
|
||||
if e.caret == r.start && pi > 0 { (doc.para_ranges()[pi - 1].start, None) } else { (r.start, None) }
|
||||
}
|
||||
"paraEnd" | "paraDown" => {
|
||||
let ranges = doc.para_ranges();
|
||||
let pi = doc.para_of(e.caret);
|
||||
if e.caret == ranges[pi].end && pi + 1 < ranges.len() { (ranges[pi + 1].end, None) } else { (ranges[pi].end, None) }
|
||||
}
|
||||
"start" => (0, None),
|
||||
"end" | "docEnd" => (n, None),
|
||||
_ => {
|
||||
let _ = s;
|
||||
return Err(bad("text.moveCaret", format!("unknown target {to}")));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.moveCaret")?;
|
||||
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.moveCaret", "not a text layer"))?;
|
||||
let e = session_on(s, lid);
|
||||
let to =
|
||||
str_p(p, "to").ok_or_else(|| bad("text.moveCaret", "give to: left|right|wordLeft|wordRight|lineStart|lineEnd|up|down|paraStart|paraEnd|start|end"))?;
|
||||
let extend = b_p(p, "extend").unwrap_or(false);
|
||||
// Extending moves the caret itself, not the collapsed selection edge.
|
||||
let probe = if extend { TextEdit { anchor: e.caret, ..e.clone() } } else { e.clone() };
|
||||
let (c, goal) = move_target(s, &probe, &doc, to)?;
|
||||
let mut e = e;
|
||||
e.caret = c;
|
||||
if !extend {
|
||||
e.anchor = c;
|
||||
}
|
||||
e.goal_x = goal;
|
||||
e.pending = None;
|
||||
s.state.text_edit = Some(e);
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Replace the selection (or `range`) with `text`; the caret goes after it.
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.insert")?;
|
||||
let text = str_p(p, "text").ok_or_else(|| bad("text.insert", "give text"))?.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let e = session_on(s, lid);
|
||||
let r = range_p(p).unwrap_or_else(|| e.range());
|
||||
let pending = e.pending.clone();
|
||||
let merge = str_p(p, "merge").map(str::to_string);
|
||||
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
||||
let n = doc.char_len();
|
||||
let r = r.start.min(n)..r.end.min(n);
|
||||
doc.replace_range(r.clone(), &text, pending.as_ref());
|
||||
let c = r.start + text.chars().count();
|
||||
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
||||
ed.anchor = c;
|
||||
ed.caret = c;
|
||||
ed.pending = None;
|
||||
ed.goal_x = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Delete the selection, or one character / word before (backward) or after the caret.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.delete")?;
|
||||
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.delete", "not a text layer"))?;
|
||||
let e = session_on(s, lid);
|
||||
let n = doc.char_len();
|
||||
let forward = str_p(p, "direction") == Some("forward");
|
||||
let word = b_p(p, "word").unwrap_or(false);
|
||||
let r = match range_p(p) {
|
||||
Some(r) => r.start.min(n)..r.end.min(n),
|
||||
None if !e.is_empty() => e.range(),
|
||||
None if forward => e.caret..if word { effectcraft_keyframe::text_doc::word_right(&doc.text, e.caret) } else { (e.caret + 1).min(n) },
|
||||
None => (if word { effectcraft_keyframe::text_doc::word_left(&doc.text, e.caret) } else { e.caret.saturating_sub(1) })..e.caret,
|
||||
};
|
||||
if r.is_empty() {
|
||||
return Ok(state_json(s));
|
||||
}
|
||||
let merge = str_p(p, "merge").map(str::to_string);
|
||||
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
||||
doc.replace_range(r.clone(), "", None);
|
||||
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
||||
ed.anchor = r.start;
|
||||
ed.caret = r.start;
|
||||
ed.pending = None;
|
||||
ed.goal_x = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Selected text (plain) of the session, and its formatted fragment.
|
||||
fn selection_doc(s: &Session) -> Option<TextDoc> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
let doc = layer_doc(s, e.layer)?;
|
||||
(!e.is_empty()).then(|| doc.slice(e.range()))
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy while editing text.
|
||||
pub(crate) fn copy(s: &mut Session) -> Result<Value> {
|
||||
let frag = selection_doc(s).ok_or_else(|| bad("edit.copy", "select some text first"))?;
|
||||
let text = frag.text.clone();
|
||||
s.state.text_clipboard = Some(frag);
|
||||
Ok(json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Cut while editing text.
|
||||
pub(crate) fn cut(s: &mut Session) -> Result<Value> {
|
||||
let r = copy(s)?;
|
||||
delete(s, &json!({}))?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste while editing text: the copied text with its formatting, or `text` (from the
|
||||
/// system clipboard) in the caret's style when it's something else.
|
||||
pub(crate) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.clone() else { return Err(bad("edit.paste", "not editing text")) };
|
||||
let clip = s.state.text_clipboard.clone();
|
||||
match (str_p(p, "text"), clip) {
|
||||
(Some(t), Some(c)) if t.replace("\r\n", "\n") != c.text => insert(s, &json!({"text": t})),
|
||||
(_, Some(c)) => {
|
||||
let r = e.range();
|
||||
edit_doc(s, "Paste", None, e.layer, |doc, st| {
|
||||
doc.insert_doc(r.clone(), &c);
|
||||
let at = r.start + c.char_len();
|
||||
if let Some(ed) = st.text_edit.as_mut() {
|
||||
ed.anchor = at;
|
||||
ed.caret = at;
|
||||
ed.pending = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
(Some(t), None) => insert(s, &json!({"text": t})),
|
||||
(None, None) => Err(bad("edit.paste", "nothing to paste")),
|
||||
}
|
||||
}
|
||||
|
||||
fn editing_or_text_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.text_edit.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
super::has_comp(s)?;
|
||||
let comp = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().any(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("edit text or select a text layer".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_text_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
editing_or_text_layer(s)?;
|
||||
if s.state.text_clipboard.is_some() { Ok(()) } else { Err("copy some text first".into()) }
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste Text and Match Formatting: the clipboard's plain text in the style at the caret.
|
||||
fn paste_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = match str_p(p, "text") {
|
||||
Some(t) => t.to_string(),
|
||||
None => s.state.text_clipboard.as_ref().map(|c| c.text.clone()).ok_or_else(|| bad("edit.pasteTextMatchFormatting", "nothing to paste"))?,
|
||||
};
|
||||
if s.state.text_edit.is_none() {
|
||||
// Not editing: replace the selected text layers' text, keeping their formatting.
|
||||
let lid = target(s, p, "edit.pasteTextMatchFormatting")?;
|
||||
edit_doc(s, "Paste Text and Match Formatting", None, lid, |doc, _| {
|
||||
doc.set_text(&text);
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let e = session_on(s, target(s, p, "edit.pasteTextMatchFormatting")?);
|
||||
let doc = layer_doc(s, e.layer).ok_or_else(|| bad("edit.pasteTextMatchFormatting", "not a text layer"))?;
|
||||
let style = e.pending.clone().unwrap_or_else(|| if e.is_empty() { doc.insertion_style(e.caret) } else { doc.style_at(e.range().start) });
|
||||
let r = e.range();
|
||||
edit_doc(s, "Paste Text and Match Formatting", None, e.layer, |doc, st| {
|
||||
doc.replace_range(r.clone(), &text, Some(&style));
|
||||
let at = r.start + text.chars().count();
|
||||
if let Some(ed) = st.text_edit.as_mut() {
|
||||
ed.anchor = at;
|
||||
ed.caret = at;
|
||||
ed.pending = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste Text Formatting Only: the copied text's character style (its first character)
|
||||
/// and paragraph settings on the selected text, or on the whole of the selected text layers.
|
||||
fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let clip = s.state.text_clipboard.clone().ok_or_else(|| bad("edit.pasteTextFormattingOnly", "copy some text first"))?;
|
||||
let style = clip.style_at(0);
|
||||
let para = clip.para(0);
|
||||
let targets: Vec<(LayerId, Option<std::ops::Range<usize>>)> = match &s.state.text_edit {
|
||||
Some(e) => vec![(e.layer, (!e.is_empty()).then(|| e.range()))],
|
||||
None => {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let ids: Vec<LayerId> = match p.get("layer") {
|
||||
Some(_) => vec![target(s, p, "edit.pasteTextFormattingOnly")?],
|
||||
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))).collect(),
|
||||
};
|
||||
ids.into_iter().map(|l| (l, None)).collect()
|
||||
}
|
||||
};
|
||||
if let Some(e) = s.state.text_edit.as_mut()
|
||||
&& e.is_empty()
|
||||
{
|
||||
// Caret only: the next typed text takes the formatting.
|
||||
e.pending = Some(style);
|
||||
return Ok(state_json(s));
|
||||
}
|
||||
for (lid, r) in targets {
|
||||
edit_doc(s, "Paste Text Formatting Only", None, lid, |doc, _| {
|
||||
let n = doc.char_len();
|
||||
let r = r.clone().unwrap_or(0..n.max(1));
|
||||
doc.apply_style(r.clone(), |st| *st = style.clone());
|
||||
doc.apply_para(r, |pp| *pp = para.clone());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
|
||||
/// layer is gone or not in the active comp.
|
||||
pub(crate) fn sanitize(s: &mut Session) {
|
||||
let Some(e) = s.state.text_edit.clone() else { return };
|
||||
match layer_doc(s, e.layer) {
|
||||
Some(d) => {
|
||||
let n = d.char_len();
|
||||
if e.anchor > n || e.caret > n {
|
||||
let ed = s.state.text_edit.as_mut().expect("checked");
|
||||
ed.anchor = ed.anchor.min(n);
|
||||
ed.caret = ed.caret.min(n);
|
||||
}
|
||||
}
|
||||
None => s.state.text_edit = None,
|
||||
}
|
||||
}
|
||||
|
||||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"text.edit",
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, select?: [anchor, caret]|\"none\", caret?, created?} → {layer, anchor, caret}",
|
||||
editing_or_text_layer,
|
||||
edit
|
||||
),
|
||||
cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit),
|
||||
cmd!(
|
||||
"text.setSelection",
|
||||
"Set Text Selection",
|
||||
[],
|
||||
None,
|
||||
"{layer?, start, end?} | {anchor, caret} | {select: \"all\"} (character indices)",
|
||||
editing_or_text_layer,
|
||||
set_selection
|
||||
),
|
||||
cmd!(
|
||||
"text.moveCaret",
|
||||
"Move Text Caret",
|
||||
[],
|
||||
None,
|
||||
"{to: left|right|wordLeft|wordRight|lineStart|lineEnd|up|down|paraStart|paraEnd|start|end, extend?}",
|
||||
editing,
|
||||
move_caret
|
||||
),
|
||||
cmd!("text.insert", "Type Text", [], None, "{layer?, text, range?: [start, end], merge?} (replaces the selection)", editing_or_text_layer, insert),
|
||||
cmd!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, direction?: backward|forward, word?, range?: [start, end], merge?}",
|
||||
editing_or_text_layer,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteTextMatchFormatting",
|
||||
"Paste Text and Match Formatting",
|
||||
["Edit"],
|
||||
None,
|
||||
"{text?} (the clipboard's text in the style at the caret)",
|
||||
editing_or_text_layer,
|
||||
paste_match
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteTextFormattingOnly",
|
||||
"Paste Text Formatting Only",
|
||||
["Edit"],
|
||||
None,
|
||||
"{layer?} (the copied text's formatting on the selected text or text layers)",
|
||||
has_text_clip,
|
||||
paste_formatting
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a layer is being edited (for the Character / Paragraph panels).
|
||||
pub fn editing_layer(s: &Session) -> Option<LayerId> {
|
||||
s.state.text_edit.as_ref().map(|e| e.layer)
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
//! Composition viewer state that agents and the CLI can drive too: snapping, Show Channel and
|
||||
//! exposure, snapshots, Fast Previews, guides dragged out of the rulers, and the Pen tool for
|
||||
//! shape layers (`shape.newPath`).
|
||||
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
use effectcraft_project::LayerSource;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
|
||||
use crate::viewer::{Channel, Snapshot};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
/// Composition ▸ Preview ▸ Fast Previews modes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FastPreviews {
|
||||
#[default]
|
||||
Off,
|
||||
AdaptiveResolution,
|
||||
Draft,
|
||||
FastDraft,
|
||||
Wireframe,
|
||||
}
|
||||
|
||||
impl FastPreviews {
|
||||
pub const ALL: [FastPreviews; 5] =
|
||||
[FastPreviews::Off, FastPreviews::AdaptiveResolution, FastPreviews::Draft, FastPreviews::FastDraft, FastPreviews::Wireframe];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FastPreviews::Off => "off",
|
||||
FastPreviews::AdaptiveResolution => "adaptive",
|
||||
FastPreviews::Draft => "draft",
|
||||
FastPreviews::FastDraft => "fastDraft",
|
||||
FastPreviews::Wireframe => "wireframe",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FastPreviews::Off => "Off (Final Quality)",
|
||||
FastPreviews::AdaptiveResolution => "Adaptive Resolution",
|
||||
FastPreviews::Draft => "Draft",
|
||||
FastPreviews::FastDraft => "Fast Draft",
|
||||
FastPreviews::Wireframe => "Wireframe",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<FastPreviews> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
FastPreviews::ALL.into_iter().find(|m| m.id().to_ascii_lowercase() == n || m.label().to_ascii_lowercase().replace(' ', "").starts_with(&n))
|
||||
}
|
||||
/// Render options for the viewer: (draft quality, resolution factor while interacting).
|
||||
pub fn render(self, interacting: bool) -> (bool, f64) {
|
||||
match self {
|
||||
FastPreviews::Off => (false, 1.0),
|
||||
FastPreviews::AdaptiveResolution => (false, if interacting { 0.25 } else { 1.0 }),
|
||||
FastPreviews::Draft => (true, 1.0),
|
||||
FastPreviews::FastDraft => (true, if interacting { 0.25 } else { 1.0 }),
|
||||
FastPreviews::Wireframe => (true, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ViewOptions {
|
||||
pub channel: Channel,
|
||||
/// Show Channel ▸ Colorize: single channels shown in their colour instead of gray.
|
||||
pub colorized: bool,
|
||||
/// Adjust Exposure, in stops.
|
||||
pub exposure: f32,
|
||||
/// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame.
|
||||
pub show_snapshot: bool,
|
||||
pub fast_previews: FastPreviews,
|
||||
}
|
||||
|
||||
fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.snapping = b_p(p, "value").unwrap_or(!s.state.snapping);
|
||||
Ok(json!(s.state.snapping))
|
||||
}
|
||||
|
||||
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "channel").unwrap_or("rgb");
|
||||
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
|
||||
// Choosing the active single channel again returns to RGB, as in After Effects.
|
||||
let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c };
|
||||
s.state.viewer.channel = c;
|
||||
if let Some(v) = b_p(p, "colorized") {
|
||||
s.state.viewer.colorized = v;
|
||||
}
|
||||
Ok(json!({"channel": c.id(), "colorized": s.state.viewer.colorized}))
|
||||
}
|
||||
|
||||
fn exposure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.state.viewer.exposure as f64;
|
||||
let v = match (f_p(p, "stops"), f_p(p, "delta")) {
|
||||
(Some(v), _) => v,
|
||||
(None, Some(d)) => cur + d,
|
||||
_ => return Err(bad("view.exposure", "pass `stops` or `delta`")),
|
||||
};
|
||||
s.state.viewer.exposure = v.clamp(-40.0, 40.0) as f32;
|
||||
Ok(json!(s.state.viewer.exposure))
|
||||
}
|
||||
|
||||
fn reset_exposure(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.state.viewer.exposure = 0.0;
|
||||
Ok(json!(0.0))
|
||||
}
|
||||
|
||||
fn take_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = s.time_of(cid);
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0);
|
||||
let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() });
|
||||
let (w, h) = (img.width, img.height);
|
||||
s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) });
|
||||
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
|
||||
Ok(json!({"width": w, "height": h, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
fn has_snapshot(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.snapshot.is_some() { Ok(()) } else { Err("take a snapshot first (Shift+F5)".into()) }
|
||||
}
|
||||
|
||||
fn show_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.viewer.show_snapshot = b_p(p, "value").unwrap_or(!s.state.viewer.show_snapshot);
|
||||
Ok(json!(s.state.viewer.show_snapshot))
|
||||
}
|
||||
|
||||
fn fast_previews(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "mode").unwrap_or("off");
|
||||
let m =
|
||||
FastPreviews::from_name(name).ok_or_else(|| bad("view.fastPreviewMode", format!("unknown mode `{name}` (off|adaptive|draft|fastDraft|wireframe)")))?;
|
||||
s.state.viewer.fast_previews = m;
|
||||
Ok(json!(m.id()))
|
||||
}
|
||||
|
||||
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, usize)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len();
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `index`"))? as usize;
|
||||
if i >= n {
|
||||
return Err(bad(cmd, format!("no guide {i} (the comp has {n})")));
|
||||
}
|
||||
Ok((cid, i))
|
||||
}
|
||||
|
||||
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.moveGuide")?;
|
||||
let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?;
|
||||
s.edit("Move Guide", merge_p(p), |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(pos))
|
||||
}
|
||||
|
||||
fn remove_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.removeGuide")?;
|
||||
s.edit("Remove Guide", None, |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] {
|
||||
match v {
|
||||
Some(Value::Array(a)) => {
|
||||
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
|
||||
[g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)]
|
||||
}
|
||||
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d),
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on
|
||||
/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are
|
||||
/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer).
|
||||
fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let mut v = pts(p.get("vertices"));
|
||||
if v.is_empty() {
|
||||
return Err(bad("shape.newPath", "need `vertices` [[x,y], …]"));
|
||||
}
|
||||
let n = v.len();
|
||||
let mut ins = pts(p.get("inTangents"));
|
||||
let mut outs = pts(p.get("outTangents"));
|
||||
ins.resize(n, [0.0; 2]);
|
||||
outs.resize(n, [0.0; 2]);
|
||||
let target = match p.get("layer") {
|
||||
Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?),
|
||||
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
|
||||
};
|
||||
if let Some(l) = target.and_then(|l| comp.layer(l))
|
||||
&& !matches!(l.source, LayerSource::Shape)
|
||||
{
|
||||
return Err(bad("shape.newPath", "the layer is not a shape layer"));
|
||||
}
|
||||
let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none());
|
||||
if comp_space {
|
||||
// Comp → layer space of the target (a new layer sits at the comp centre, unrotated).
|
||||
let inv = match target.and_then(|l| comp.layer(l)) {
|
||||
Some(l) => {
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid));
|
||||
ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
}
|
||||
None => effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-(comp.width as f64) / 2.0, -(comp.height as f64) / 2.0)),
|
||||
};
|
||||
for q in &mut v {
|
||||
let r = inv.apply(effectcraft_geom::vec2(q[0], q[1]));
|
||||
*q = [r.x, r.y];
|
||||
}
|
||||
for t in ins.iter_mut().chain(outs.iter_mut()) {
|
||||
let r = inv.apply_vec(effectcraft_geom::vec2(t[0], t[1]));
|
||||
*t = [r.x, r.y];
|
||||
}
|
||||
}
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
|
||||
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
|
||||
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
|
||||
let name = str_p(p, "name").map(str::to_string);
|
||||
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
|
||||
let lid = match target {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
|
||||
let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1));
|
||||
let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None);
|
||||
super::layer::insert_layer(proj, st, cid, l)?
|
||||
}
|
||||
};
|
||||
let mut next = proj.next_id;
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?;
|
||||
let k = contents.groups().filter(|g| g.match_id == "group").count();
|
||||
let mut ids = Ids(&mut next);
|
||||
let pg = build::shape_path(&mut ids, path);
|
||||
let path_uid = pg.uid;
|
||||
let mut items = vec![pg];
|
||||
if let Some(c) = stroke {
|
||||
items.push(build::shape_stroke(&mut ids, c, width));
|
||||
}
|
||||
if let Some(c) = fill {
|
||||
items.push(build::shape_fill(&mut ids, c));
|
||||
}
|
||||
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
|
||||
let group = g.uid;
|
||||
contents.children.insert(0, g.into());
|
||||
proj.next_id = next;
|
||||
st.selected_layers = vec![lid];
|
||||
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
|
||||
Ok((lid, group, path_uid))
|
||||
})?;
|
||||
Ok(json!({"layer": lid.0, "group": group, "path": path_uid}))
|
||||
}
|
||||
|
||||
/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`).
|
||||
pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let snap = s.snapshot.as_ref()?;
|
||||
let img = &snap.image;
|
||||
let mut out = Vec::with_capacity(img.data.len() * 4);
|
||||
for p in &img.data {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 };
|
||||
out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8));
|
||||
}
|
||||
Some((img.width, img.height, out))
|
||||
}
|
||||
|
||||
fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
|
||||
cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel),
|
||||
cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure),
|
||||
cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure),
|
||||
cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot),
|
||||
cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot),
|
||||
cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews),
|
||||
cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide),
|
||||
cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide),
|
||||
cmd!(
|
||||
"shape.newPath",
|
||||
"Pen Tool (Shape Path)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}",
|
||||
shape_layer_or_none,
|
||||
new_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ pub mod render_queue;
|
|||
mod session_settings;
|
||||
pub mod shortcuts;
|
||||
pub mod tracking;
|
||||
pub mod viewer;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ pub use effectcraft_geom as geom;
|
|||
pub use effectcraft_keyframe as keyframe;
|
||||
pub use effectcraft_project as project;
|
||||
pub use effectcraft_render as render;
|
||||
pub use effectcraft_text as text;
|
||||
pub use effectcraft_time as time;
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
|
||||
|
|
@ -178,6 +180,15 @@ pub struct EditorState {
|
|||
/// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines).
|
||||
#[serde(default)]
|
||||
pub hide_locked_masks: bool,
|
||||
/// On-canvas text editing (Type tool): the edited layer, selection and insertion style.
|
||||
#[serde(default)]
|
||||
pub text_edit: Option<commands::text_edit::TextEdit>,
|
||||
/// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only.
|
||||
#[serde(skip)]
|
||||
pub text_clipboard: Option<effectcraft_keyframe::TextDoc>,
|
||||
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[serde(default)]
|
||||
pub viewer: commands::viewer_cmds::ViewOptions,
|
||||
}
|
||||
|
||||
fn one_view() -> u8 {
|
||||
|
|
@ -258,6 +269,8 @@ pub struct Session {
|
|||
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
|
||||
/// Auto-save bookkeeping.
|
||||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -288,6 +301,7 @@ impl Default for Session {
|
|||
ui_commands: vec![],
|
||||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -399,6 +413,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 {
|
||||
|
|
@ -412,6 +427,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)
|
||||
|
|
@ -540,9 +560,13 @@ mod tests_styles;
|
|||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
#[cfg(test)]
|
||||
mod tests_text_edit;
|
||||
#[cfg(test)]
|
||||
mod tests_timeline;
|
||||
#[cfg(test)]
|
||||
mod tests_track;
|
||||
#[cfg(test)]
|
||||
mod tests_viewer;
|
||||
|
||||
/// Font families available to text layers (bundled + scanned system fonts).
|
||||
/// Text animation presets: (id, name), for menus.
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
|||
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
|
||||
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
|
||||
"view.shareViewOptions" => Some(s.state.share_view_options),
|
||||
"view.snapping" => Some(s.state.snapping),
|
||||
"layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => {
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur};
|
||||
let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None };
|
||||
|
|
@ -869,6 +870,7 @@ View
|
|||
---
|
||||
Show Grid | view.grid
|
||||
Snap to Grid | view.snapToGrid
|
||||
Snapping | view.snapping
|
||||
---
|
||||
View Options... | view.options
|
||||
Show Layer Controls | view.layerControls
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
//! On-canvas text editing and per-character styles through commands: run-scoped
|
||||
//! `layer.setText`, text.insert / delete / setSelection / moveCaret, the text clipboard and the
|
||||
//! paste-formatting commands, with undo / redo.
|
||||
|
||||
use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::text_edit::layer_doc;
|
||||
|
||||
fn session(text: &str) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let t = s.execute("layer.newText", json!({"text": text, "size": 60, "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, t)
|
||||
}
|
||||
|
||||
fn doc(s: &Session, t: u64) -> TextDoc {
|
||||
layer_doc(s, effectcraft_project::LayerId(t)).unwrap()
|
||||
}
|
||||
|
||||
fn sel(s: &Session) -> (usize, usize) {
|
||||
let e = s.state.text_edit.as_ref().unwrap();
|
||||
(e.anchor, e.caret)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_with_a_range_creates_runs_and_undoes() {
|
||||
let (mut s, t) = session("Hello World");
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [6, 11], "size": 30, "fill": "#ff0000", "kerning": "optical"})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.runs().len(), 2);
|
||||
assert_eq!(d.style_at(0).size, 60.0);
|
||||
assert_eq!(d.style_at(7).size, 30.0);
|
||||
assert_eq!(d.style_at(7).fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(d.style_at(7).kerning, Kerning::Optical);
|
||||
// Paragraph attributes apply to the paragraphs the range touches; document ones to all.
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [0, 1], "justify": "justifyAll", "indentFirst": 20, "spaceAfter": 6, "direction": "rtl"})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!((d.justify, d.indent_first, d.space_after), (Justify::JustifyAll, 20.0, 6.0));
|
||||
// Unknown attributes and bad values are errors (nothing changes).
|
||||
assert!(s.execute("layer.setText", json!({"layer": t, "sizee": 3})).is_err());
|
||||
assert!(s.execute("layer.setText", json!({"layer": t, "size": "big"})).is_err());
|
||||
assert!(s.execute("edit.undo", json!({})).is_ok());
|
||||
assert_eq!(doc(&s, t).justify, Justify::Left);
|
||||
assert!(s.execute("edit.undo", json!({})).is_ok());
|
||||
assert!(doc(&s, t).is_uniform());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).runs().len(), 2);
|
||||
// Without a range everything changes and the runs collapse again.
|
||||
s.execute("layer.setText", json!({"layer": t, "size": 44, "superscript": true})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert!(d.runs().iter().all(|r| r.style.size == 44.0 && r.style.baseline == BaselineOption::Superscript));
|
||||
// Replacing a range's text keeps the formatting around it.
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [0, 5], "text": "Howdy", "fauxBold": true})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "Howdy World");
|
||||
assert!(d.style_at(2).faux_bold && !d.style_at(7).faux_bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_deleting_and_caret_movement() {
|
||||
let (mut s, t) = session("abc def");
|
||||
s.execute("text.edit", json!({"layer": t})).unwrap();
|
||||
assert_eq!(sel(&s), (0, 7), "editing starts with everything selected");
|
||||
s.execute("text.setSelection", json!({"start": 3})).unwrap();
|
||||
s.execute("text.insert", json!({"text": "XY", "merge": "typing-1"})).unwrap();
|
||||
s.execute("text.insert", json!({"text": "Z", "merge": "typing-1"})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcXYZ def");
|
||||
assert_eq!(sel(&s), (6, 6));
|
||||
// One undo step for the merged typing.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abc def");
|
||||
assert_eq!(sel(&s), (6, 6));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("text.setSelection", json!({"start": 6})).unwrap();
|
||||
// Backspace / Delete, by character and by word.
|
||||
s.execute("text.delete", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcXY def");
|
||||
s.execute("text.delete", json!({"direction": "forward", "word": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcXY");
|
||||
s.execute("text.delete", json!({"word": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "");
|
||||
s.execute("text.insert", json!({"text": "one two\nthree"})).unwrap();
|
||||
assert_eq!(sel(&s), (13, 13));
|
||||
// Caret movement: words, lines, up / down, extend.
|
||||
let mv = |s: &mut Session, to: &str, extend: bool| -> (usize, usize) {
|
||||
let r = s.execute("text.moveCaret", json!({"to": to, "extend": extend})).unwrap();
|
||||
(r["anchor"].as_u64().unwrap() as usize, r["caret"].as_u64().unwrap() as usize)
|
||||
};
|
||||
assert_eq!(mv(&mut s, "lineStart", false), (8, 8));
|
||||
assert_eq!(mv(&mut s, "up", false).1, 0);
|
||||
assert_eq!(mv(&mut s, "wordRight", false), (3, 3));
|
||||
assert_eq!(mv(&mut s, "wordRight", true), (3, 7));
|
||||
assert_eq!(mv(&mut s, "left", false), (3, 3), "left collapses a selection to its start");
|
||||
assert_eq!(mv(&mut s, "lineEnd", false), (7, 7));
|
||||
assert_eq!(mv(&mut s, "down", false).1, 13);
|
||||
assert_eq!(mv(&mut s, "start", true), (13, 0));
|
||||
assert_eq!(mv(&mut s, "paraEnd", false), (7, 7));
|
||||
// Select All while editing selects the text, not the layers.
|
||||
s.execute("edit.selectAll", json!({})).unwrap();
|
||||
assert_eq!(sel(&s), (0, 13));
|
||||
// Undo past the insertion clamps the selection.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(sel(&s).0 <= doc(&s, t).char_len() && sel(&s).1 <= doc(&s, t).char_len());
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_style_applies_to_the_next_typed_text() {
|
||||
let (mut s, t) = session("ab");
|
||||
s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [1, 1], "size": 12, "fill": [0, 1, 0]})).unwrap();
|
||||
assert!(doc(&s, t).is_uniform(), "a caret-only change doesn't touch the text");
|
||||
s.execute("text.insert", json!({"text": "Q"})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "aQb");
|
||||
assert_eq!(d.style_at(1).size, 12.0);
|
||||
assert_eq!(d.style_at(1).fill, [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(d.style_at(2).size, 60.0);
|
||||
// Typing again continues the new style (the character before the caret).
|
||||
s.execute("text.insert", json!({"text": "R"})).unwrap();
|
||||
assert_eq!(doc(&s, t).style_at(2).size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_and_paste_formatting() {
|
||||
let (mut s, t) = session("red plain");
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "fill": "#ff0000", "size": 20})).unwrap();
|
||||
s.execute("text.edit", json!({"layer": t, "select": [0, 3]})).unwrap();
|
||||
let r = s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(r["text"], "red");
|
||||
// Paste keeps the copied formatting.
|
||||
s.execute("text.setSelection", json!({"start": 9})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "red plainred");
|
||||
assert_eq!(d.style_at(10).fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(sel(&s), (12, 12));
|
||||
// Different text from the system clipboard pastes in the caret's style.
|
||||
s.execute("text.setSelection", json!({"start": 4})).unwrap();
|
||||
s.execute("edit.paste", json!({"text": "new "})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "red new plainred");
|
||||
assert_eq!(doc(&s, t).style_at(5).size, 60.0);
|
||||
// Paste Text and Match Formatting: the copied text in the caret's style.
|
||||
s.execute("text.setSelection", json!({"start": 8})).unwrap();
|
||||
s.execute("edit.pasteTextMatchFormatting", json!({})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.text, "red new redplainred");
|
||||
assert_eq!(d.style_at(9).fill, [1.0; 4]);
|
||||
// Paste Text Formatting Only onto a selection.
|
||||
s.execute("text.setSelection", json!({"start": 11, "end": 16})).unwrap();
|
||||
s.execute("edit.pasteTextFormattingOnly", json!({})).unwrap();
|
||||
let d = doc(&s, t);
|
||||
assert_eq!(d.style_at(12).fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(d.style_at(12).size, 20.0);
|
||||
// Cut removes the selection.
|
||||
s.execute("text.setSelection", json!({"start": 0, "end": 4})).unwrap();
|
||||
assert_eq!(s.execute("edit.cut", json!({})).unwrap()["text"], "red ");
|
||||
assert_eq!(doc(&s, t).text, "new redplainred");
|
||||
// Not editing: Paste Text Formatting Only restyles the whole selected text layer.
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [t]})).unwrap();
|
||||
s.execute("edit.pasteTextFormattingOnly", json!({})).unwrap();
|
||||
assert!(doc(&s, t).is_uniform());
|
||||
assert_eq!(doc(&s, t).size, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_layers_point_box_and_empty_cleanup() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
|
||||
// Leaving an untouched Type-tool layer deletes it.
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
|
||||
let d = doc(&s, b);
|
||||
assert_eq!(d.box_size, Some([300.0, 120.0]));
|
||||
assert_eq!(d.box_pos, [-150.0, -60.0]);
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.name, "Boxed", "named after its text");
|
||||
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
|
||||
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
// Resizing the box (layer space) is an attribute too.
|
||||
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
|
||||
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
|
||||
s.execute("layer.setText", json!({"layer": b, "vertical": true})).unwrap();
|
||||
assert!(doc(&s, b).vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_state_round_trips_through_serde() {
|
||||
let (mut s, t) = session("abc");
|
||||
s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap();
|
||||
let j = serde_json::to_value(&s.state).unwrap();
|
||||
let back: crate::EditorState = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back.text_edit, s.state.text_edit);
|
||||
}
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots,
|
||||
//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and
|
||||
//! the Graph Editor transform box. Every edit undoes.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap};
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
#[test]
|
||||
fn snap_picks_nearest_target_within_tolerance() {
|
||||
let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)];
|
||||
let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap();
|
||||
assert_eq!(s.delta, [-3.0, -1.0]);
|
||||
assert_eq!(s.at, [100.0, 100.0]);
|
||||
// Out of tolerance: nothing.
|
||||
assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none());
|
||||
// Of several sources, the one nearest a target snaps.
|
||||
let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap();
|
||||
assert_eq!(s.delta, [1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
|
||||
let t = [
|
||||
SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid),
|
||||
SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide),
|
||||
SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid),
|
||||
];
|
||||
// x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to
|
||||
// the horizontal one.
|
||||
let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap();
|
||||
assert_eq!(s.delta, [2.0, -3.0]);
|
||||
assert_eq!(s.hits.len(), 2);
|
||||
assert_eq!(s.hits[0].source, SnapSource::Guide);
|
||||
assert_eq!(s.at, [54.0, 20.0]);
|
||||
// A point at the same distance as a line beats it (it snaps both axes).
|
||||
let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))];
|
||||
let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap();
|
||||
assert_eq!(s.hits[0].kind, SnapKind::Point);
|
||||
assert_eq!(s.delta, [-2.0, 0.0]);
|
||||
// Equal point distances: lower priority value (vertex) wins over a layer feature.
|
||||
let t3 =
|
||||
[SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))];
|
||||
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||||
let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 };
|
||||
let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts);
|
||||
// Layer a's corner (270, 155) is a target; layer b (dragged) is not.
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0]));
|
||||
assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b))));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0]));
|
||||
// Dragging a corner near layer a's corner lands on it (edges snap x and y).
|
||||
let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap();
|
||||
assert_eq!(hit.at, [270.0, 155.0]);
|
||||
assert_eq!(hit.delta, [-2.0, -2.0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels, exposure, snapshot
|
||||
|
||||
#[test]
|
||||
fn channel_and_exposure_pixel_transforms() {
|
||||
// Premultiplied: straight (200, 100, 50) at 50% alpha.
|
||||
let px = [100u8, 50, 25, 128];
|
||||
let run = |c: Channel, col: bool, stops: f32| {
|
||||
let mut v = [px];
|
||||
display_transform(&mut v, c, col, stops);
|
||||
v[0]
|
||||
};
|
||||
assert_eq!(run(Channel::Rgb, false, 0.0), px);
|
||||
assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]);
|
||||
assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]);
|
||||
assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]);
|
||||
assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]);
|
||||
let st = run(Channel::RgbStraight, false, 0.0);
|
||||
assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}");
|
||||
// +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity.
|
||||
let mut g = [[128u8, 128, 128, 255]];
|
||||
display_transform(&mut g, Channel::Rgb, false, 1.0);
|
||||
assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]);
|
||||
let mut g = [[128u8, 128, 128, 255]];
|
||||
display_transform(&mut g, Channel::Rgb, false, -1.0);
|
||||
assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]);
|
||||
assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200);
|
||||
// Exposure applies to a single channel too, and keeps alpha untouched in RGB.
|
||||
assert!(run(Channel::Red, false, 1.0)[0] > 100);
|
||||
assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_exposure_and_fast_preview_commands() {
|
||||
let mut s = comp();
|
||||
s.execute("view.channel", json!({"channel": "alpha"})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::Alpha);
|
||||
s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::Rgb);
|
||||
s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::RgbStraight);
|
||||
assert!(s.state.viewer.colorized);
|
||||
s.execute("view.exposure", json!({"stops": 1.5})).unwrap();
|
||||
s.execute("view.exposure", json!({"delta": -0.5})).unwrap();
|
||||
assert_eq!(s.state.viewer.exposure, 1.0);
|
||||
s.execute("view.resetExposure", json!({})).unwrap();
|
||||
assert_eq!(s.state.viewer.exposure, 0.0);
|
||||
s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap();
|
||||
assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft);
|
||||
assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err());
|
||||
let was = s.state.snapping;
|
||||
s.execute("view.snapping", json!({})).unwrap();
|
||||
assert_eq!(s.state.snapping, !was);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_store_and_restore() {
|
||||
let mut s = comp();
|
||||
assert!(!s.is_enabled("view.showSnapshot"));
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap();
|
||||
let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
|
||||
// The project changes; the snapshot keeps the old frame.
|
||||
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap();
|
||||
let snap = s.snapshot.clone().unwrap();
|
||||
assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
|
||||
let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
|
||||
assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]);
|
||||
s.execute("view.showSnapshot", json!({"value": true})).unwrap();
|
||||
assert!(s.state.viewer.show_snapshot);
|
||||
let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap();
|
||||
assert_eq!((w, h), (160, 90));
|
||||
assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
|
||||
s.execute("edit.purge", json!({"what": "snapshot"})).unwrap();
|
||||
assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_move_and_remove_with_undo() {
|
||||
let mut s = comp();
|
||||
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap();
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0);
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0);
|
||||
s.execute("view.removeGuide", json!({"index": 0})).unwrap();
|
||||
assert!(s.active_comp().unwrap().guides.is_empty());
|
||||
assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err());
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().guides.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_of_interest_renders_and_crops() {
|
||||
let mut s = comp();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap();
|
||||
s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let full = s.render(cid, Tick::ZERO, Default::default());
|
||||
let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() });
|
||||
assert_eq!((roi.width, roi.height), (240, 160));
|
||||
for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] {
|
||||
assert_eq!(roi.get(x, y), full.get(x + 200, y + 100));
|
||||
}
|
||||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!((c.width, c.height), (240, 160));
|
||||
let cropped = s.render(cid, Tick::ZERO, Default::default());
|
||||
assert_eq!(cropped.get(30, 50), roi.get(30, 50));
|
||||
assert_eq!(cropped.get(30, 50)[3], 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shape pen and vertices
|
||||
|
||||
fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||||
let l = layer(s, lid);
|
||||
l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pen_builds_shape_group_and_renders() {
|
||||
let mut s = comp();
|
||||
// Nothing selected: a new shape layer; vertices in comp space.
|
||||
let r = s
|
||||
.execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0}))
|
||||
.unwrap();
|
||||
let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||||
let l = layer(&s, lid);
|
||||
assert!(matches!(l.source, effectcraft_project::LayerSource::Shape));
|
||||
let g = l.props.find_group(group).unwrap();
|
||||
assert_eq!(g.name, "Shape 1");
|
||||
let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||
assert_eq!(items, vec!["path", "fill"]);
|
||||
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1");
|
||||
// Layer space: the layer sits at the comp centre.
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]);
|
||||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||||
assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(img.get(50, 50)[3], 0.0);
|
||||
// With the shape layer selected the Pen adds a second group (with a stroke) to it.
|
||||
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap();
|
||||
assert_eq!(r2["layer"].as_u64(), Some(lid));
|
||||
let l = layer(&s, lid);
|
||||
let names: Vec<String> = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
|
||||
assert_eq!(names, vec!["Shape 2", "Shape 1"]);
|
||||
let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap();
|
||||
let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||
assert_eq!(items, vec!["path", "stroke", "fill"]);
|
||||
s.undo();
|
||||
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
|
||||
// Not a shape layer → error.
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
|
||||
let mut s = comp();
|
||||
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap();
|
||||
let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||||
// Pen continues the path: add a vertex, move it, close.
|
||||
s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap();
|
||||
s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap();
|
||||
let sp = shape_path(&s, lid, path);
|
||||
assert_eq!(sp.vertices.len(), 4);
|
||||
assert!(sp.closed);
|
||||
// Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint.
|
||||
let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap();
|
||||
assert_eq!(i.as_u64(), Some(1));
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]);
|
||||
// Convert Vertex: corner → smooth → corner.
|
||||
let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||||
assert_eq!(c["smooth"], json!(true));
|
||||
let sp = shape_path(&s, lid, path);
|
||||
// Neighbours (50,0) and (100,100): tangent along them, a sixth of the way.
|
||||
assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]);
|
||||
assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]);
|
||||
s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||||
s.undo();
|
||||
assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||||
// Delete Vertex (selection-based).
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap();
|
||||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 4);
|
||||
s.undo();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 5);
|
||||
// Free transform of the whole shape path (scale 200% about the bbox centre).
|
||||
s.execute("mask.selectVertices", json!({"vertices": []})).unwrap();
|
||||
s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]);
|
||||
// Masks work through the same commands.
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap();
|
||||
let l = layer(&s, sol);
|
||||
let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert_eq!(mp.vertices.len(), 4);
|
||||
assert_eq!(mp.vertices[3], [50.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_segment_keeps_curve_shape() {
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
let mut sp = ShapePath {
|
||||
vertices: vec![[0.0, 0.0], [100.0, 0.0]],
|
||||
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
|
||||
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
|
||||
closed: false,
|
||||
};
|
||||
let seg = |sp: &ShapePath, i: usize, t: f64| {
|
||||
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);
|
||||
let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]];
|
||||
let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]];
|
||||
let u = 1.0 - t;
|
||||
let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k];
|
||||
[f(0), f(1)]
|
||||
};
|
||||
let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect();
|
||||
crate::commands::split_segment_for_tests(&mut sp, 0, 0.3);
|
||||
assert_eq!(sp.vertices.len(), 3);
|
||||
for i in 0..2 {
|
||||
for j in 0..=50 {
|
||||
let p = seg(&sp, i, j as f64 / 50.0);
|
||||
let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min);
|
||||
assert!(d < 0.2, "{p:?} {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion paths and graph box
|
||||
|
||||
fn animated_position(s: &mut Session) -> u64 {
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] {
|
||||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap();
|
||||
}
|
||||
lid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_key_drag_edits_value_and_tangents() {
|
||||
let mut s = comp();
|
||||
let lid = animated_position(&mut s);
|
||||
// Dragging the middle key's dot sets that key's value (merged drag = one undo step).
|
||||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap();
|
||||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap();
|
||||
let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone();
|
||||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0]));
|
||||
assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0]));
|
||||
// Tangent drag: out handle set, in mirrored, no longer auto.
|
||||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap();
|
||||
let k = pos(&s).keys[1].clone();
|
||||
assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]);
|
||||
assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]);
|
||||
assert!(!k.spatial_auto);
|
||||
// The motion path passes through the key and bulges along the tangent.
|
||||
let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3();
|
||||
assert_eq!([mid[0], mid[1]], [320.0, 140.0]);
|
||||
// Alt-drag (break): only the in handle changes.
|
||||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap();
|
||||
let k = pos(&s).keys[1].clone();
|
||||
assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0]));
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert!(pos(&s).keys[1].spatial_auto);
|
||||
s.undo();
|
||||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_transform_box_scales_key_times_and_values() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid;
|
||||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
// Time: scale 2× about 0 s; value: scale 0.5 about 100.
|
||||
s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect();
|
||||
assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]);
|
||||
assert_eq!(s.state.selected_keys.len(), 3);
|
||||
// Alt-drag of the last key in the timeline: scale the group about the first key; times
|
||||
// land on frames.
|
||||
s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
let got: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(got, vec![0.0, 1.0, 2.0]);
|
||||
s.undo();
|
||||
s.undo();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::<Vec<_>>(), vec![0.0, 1.0, 2.0]);
|
||||
assert_eq!(p.keys[1].value.as_f64(), 50.0);
|
||||
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and
|
||||
//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot.
|
||||
//!
|
||||
//! Nothing here touches the project; edits go through commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_geom::vec2;
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
/// What a snap target is: a point (snaps both axes) or an axis-aligned line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SnapKind {
|
||||
Point,
|
||||
/// A vertical line at `pos[0]` (snaps x only).
|
||||
VLine,
|
||||
/// A horizontal line at `pos[1]` (snaps y only).
|
||||
HLine,
|
||||
}
|
||||
|
||||
/// Where a snap target comes from (for feedback: the highlighted box / cross).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SnapSource {
|
||||
Layer(LayerId),
|
||||
Comp,
|
||||
Guide,
|
||||
Grid,
|
||||
/// A mask or shape path vertex of a layer.
|
||||
Vertex(LayerId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SnapTarget {
|
||||
pub kind: SnapKind,
|
||||
/// Comp pixels.
|
||||
pub pos: [f64; 2],
|
||||
/// Lower wins when two targets are equally near (vertices and layer features before
|
||||
/// guides, guides before the grid).
|
||||
pub priority: u8,
|
||||
pub source: SnapSource,
|
||||
}
|
||||
|
||||
impl SnapTarget {
|
||||
pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::Point, pos, priority, source }
|
||||
}
|
||||
pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source }
|
||||
}
|
||||
pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Priorities of the standard targets.
|
||||
pub const PRI_VERTEX: u8 = 0;
|
||||
pub const PRI_LAYER: u8 = 1;
|
||||
pub const PRI_COMP: u8 = 2;
|
||||
pub const PRI_GUIDE: u8 = 3;
|
||||
pub const PRI_GRID: u8 = 4;
|
||||
|
||||
/// A snap: move the dragged feature by `delta` (comp pixels); `hits` are the targets it lands on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Snap {
|
||||
pub delta: [f64; 2],
|
||||
pub hits: Vec<SnapTarget>,
|
||||
/// The snapped feature position.
|
||||
pub at: [f64; 2],
|
||||
}
|
||||
|
||||
fn better(a: (f64, u8), b: (f64, u8)) -> bool {
|
||||
if (a.0 - b.0).abs() < 1e-6 { a.1 < b.1 } else { a.0 < b.0 }
|
||||
}
|
||||
|
||||
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
|
||||
/// A point target snaps both axes and wins when it is at least as near as the best lines;
|
||||
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
|
||||
/// the lower priority value.
|
||||
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
|
||||
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
for s in sources {
|
||||
for t in targets {
|
||||
let (d, slot) = match t.kind {
|
||||
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt),
|
||||
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x),
|
||||
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y),
|
||||
};
|
||||
if d > tol {
|
||||
continue;
|
||||
}
|
||||
let key = (d, t.priority);
|
||||
if slot.as_ref().is_none_or(|(k, _, _)| better(key, *k)) {
|
||||
*slot = Some((key, *s, *t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
|
||||
if let Some((k, s, t)) = best_pt
|
||||
&& k.0 <= line_best + 1e-9
|
||||
{
|
||||
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
|
||||
}
|
||||
if best_x.is_none() && best_y.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut delta = [0.0; 2];
|
||||
let mut hits = vec![];
|
||||
let mut at = [f64::NAN; 2];
|
||||
if let Some((_, s, t)) = best_x {
|
||||
delta[0] = t.pos[0] - s[0];
|
||||
at = [t.pos[0], s[1]];
|
||||
hits.push(t);
|
||||
}
|
||||
if let Some((_, s, t)) = best_y {
|
||||
delta[1] = t.pos[1] - s[1];
|
||||
at = if at[0].is_nan() { [s[0], t.pos[1]] } else { [at[0], t.pos[1]] };
|
||||
hits.push(t);
|
||||
}
|
||||
Some(Snap { delta, hits, at })
|
||||
}
|
||||
|
||||
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
|
||||
/// (2D layers; `None` for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let mut out = vec![];
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
if let Some(b) = effectcraft_render::content_bounds(ctx, layer) {
|
||||
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] {
|
||||
let q = m.apply(vec2(p[0], p[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
}
|
||||
if let Some(tr) = layer.transform() {
|
||||
let a = ctx.v3(layer, tr, "anchor", [0.0; 3]);
|
||||
let q = m.apply(vec2(a[0], a[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mask and shape-path vertices of a layer in comp space.
|
||||
pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
let mut out = vec![];
|
||||
if let Some(masks) = layer.masks() {
|
||||
for g in masks.groups() {
|
||||
if let Some(sp) = g.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) {
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = m.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uid, sp) in shape_paths(ctx, layer) {
|
||||
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY);
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = pm.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Shape-layer Path items (uid, evaluated path in the item's group space).
|
||||
pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyframe::ShapePath)> {
|
||||
let mut out = vec![];
|
||||
let Some(c) = layer.props.sub("contents") else { return out };
|
||||
fn walk(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, out: &mut Vec<(u64, effectcraft_keyframe::ShapePath)>) {
|
||||
for sub in g.groups() {
|
||||
if sub.match_id == "path"
|
||||
&& let Some(sp) = sub.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned())
|
||||
{
|
||||
out.push((sub.uid, sp));
|
||||
} else if sub.match_id == "group" || sub.match_id == "contents" {
|
||||
walk(ctx, layer, sub, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(ctx, layer, c, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Which target families are on.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SnapOptions {
|
||||
pub guides: bool,
|
||||
pub grid: bool,
|
||||
pub grid_spacing: f64,
|
||||
}
|
||||
|
||||
/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges
|
||||
/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are
|
||||
/// skipped.
|
||||
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let comp = ctx.comp;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let mut out = vec![
|
||||
SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::vline(w, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(h, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp),
|
||||
];
|
||||
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) {
|
||||
let f = layer_features(ctx, l);
|
||||
// Edges of unrotated layers are lines too.
|
||||
if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 {
|
||||
for x in [f[0][0], f[1][0]] {
|
||||
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
for y in [f[0][1], f[3][1]] {
|
||||
out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
}
|
||||
out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
}
|
||||
if opts.guides {
|
||||
for g in &comp.guides {
|
||||
out.push(if g.vertical {
|
||||
SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
} else {
|
||||
SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
});
|
||||
}
|
||||
}
|
||||
if opts.grid && opts.grid_spacing >= 1.0 {
|
||||
let s = opts.grid_spacing;
|
||||
let mut x = 0.0;
|
||||
while x <= w + 1e-9 && out.len() < 20_000 {
|
||||
out.push(SnapTarget::vline(x, PRI_GRID, SnapSource::Grid));
|
||||
x += s;
|
||||
}
|
||||
let mut y = 0.0;
|
||||
while y <= h + 1e-9 && out.len() < 40_000 {
|
||||
out.push(SnapTarget::hline(y, PRI_GRID, SnapSource::Grid));
|
||||
y += s;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels and exposure
|
||||
|
||||
/// Show Channel and Color Management Settings.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
Rgb,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Alpha,
|
||||
RgbStraight,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub const ALL: [Channel; 6] = [Channel::Rgb, Channel::Red, Channel::Green, Channel::Blue, Channel::Alpha, Channel::RgbStraight];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Rgb => "rgb",
|
||||
Channel::Red => "red",
|
||||
Channel::Green => "green",
|
||||
Channel::Blue => "blue",
|
||||
Channel::Alpha => "alpha",
|
||||
Channel::RgbStraight => "rgbStraight",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Rgb => "RGB",
|
||||
Channel::Red => "Red",
|
||||
Channel::Green => "Green",
|
||||
Channel::Blue => "Blue",
|
||||
Channel::Alpha => "Alpha",
|
||||
Channel::RgbStraight => "RGB Straight",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Channel> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
Channel::ALL.into_iter().find(|c| c.id().to_ascii_lowercase() == n || c.label().to_ascii_lowercase().replace(' ', "") == n)
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
fn linear_to_srgb(v: f32) -> f32 {
|
||||
let v = v.max(0.0);
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// 8-bit exposure lookup table: `stops` applied in linear light.
|
||||
pub fn exposure_lut(stops: f32) -> [u8; 256] {
|
||||
let k = 2f32.powf(stops);
|
||||
let mut lut = [0u8; 256];
|
||||
for (i, o) in lut.iter_mut().enumerate() {
|
||||
let lin = srgb_to_linear(i as f32 / 255.0) * k;
|
||||
*o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place.
|
||||
/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque
|
||||
/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the
|
||||
/// unpremultiplied colour, opaque.
|
||||
pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) {
|
||||
let lut = (stops != 0.0).then(|| exposure_lut(stops));
|
||||
let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v);
|
||||
for p in px.iter_mut() {
|
||||
let [r, g, b, a] = *p;
|
||||
*p = match channel {
|
||||
Channel::Rgb => {
|
||||
if lut.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Exposure on straight colour, re-premultiplied.
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8;
|
||||
[pm(r), pm(g), pm(b), a]
|
||||
}
|
||||
Channel::Red | Channel::Green | Channel::Blue => {
|
||||
let (i, c) = match channel {
|
||||
Channel::Red => (0, r),
|
||||
Channel::Green => (1, g),
|
||||
_ => (2, b),
|
||||
};
|
||||
let v = ex(c);
|
||||
if colorized {
|
||||
let mut o = [0, 0, 0, 255];
|
||||
o[i] = v;
|
||||
o
|
||||
} else {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
}
|
||||
Channel::Alpha => {
|
||||
let v = ex(a);
|
||||
[v, v, v, 255]
|
||||
}
|
||||
Channel::RgbStraight => {
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
[ex(un(r)), ex(un(g)), ex(un(b)), 255]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub comp: effectcraft_project::ItemId,
|
||||
pub time: Tick,
|
||||
/// Render scale the image was taken at.
|
||||
pub scale: f64,
|
||||
pub image: Arc<Image>,
|
||||
}
|
||||
|
|
@ -175,6 +175,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
|
|||
// Output renders always look through the comp's active camera.
|
||||
view: None,
|
||||
backend: effectcraft_render::Backend::Auto,
|
||||
roi: None,
|
||||
};
|
||||
let mut r = Renderer::new(job.project, job.footage, opts);
|
||||
r.expr = job.expr;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ workspace = true
|
|||
effectcraft-time = { workspace = true }
|
||||
effectcraft-geom = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
effectcraft-path = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@ pub enum Req {
|
|||
SourceRect { comp: u64, layer: u64, t: Secs, extents: bool },
|
||||
/// `[[time, duration, comment, chapter, url], …]` for a layer, or the comp when `layer` is None.
|
||||
Markers { comp: u64, layer: Option<u64> },
|
||||
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
|
||||
/// `layer.setText` keys), or null for other properties.
|
||||
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
|
||||
}
|
||||
|
||||
/// A text document as the style API's JSON.
|
||||
pub fn doc_json(d: &effectcraft_keyframe::TextDoc) -> String {
|
||||
use effectcraft_keyframe::text_doc::{char_style_json, para_style_json};
|
||||
let runs: Vec<serde_json::Value> = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect();
|
||||
let paras: Vec<serde_json::Value> = d.paras().iter().map(para_style_json).collect();
|
||||
serde_json::json!({"doc": d, "text": d.text, "runs": runs, "paras": paras}).to_string()
|
||||
}
|
||||
|
||||
/// Plain data answer, converted to a JS value by the runtime.
|
||||
|
|
@ -292,6 +303,16 @@ impl<'a> Resolver<'a> {
|
|||
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
|
||||
value_resp(&v, prop_dims(l, p))
|
||||
}
|
||||
Req::Doc { comp, layer, path, t, pre } => {
|
||||
let (l, p) = self.prop(*comp, *layer, path)?;
|
||||
let t = f64::from_bits(*t);
|
||||
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
|
||||
match v {
|
||||
Value::Text(d) => Resp::Str(doc_json(&d)),
|
||||
Value::Str(s) => Resp::Str(doc_json(&effectcraft_keyframe::TextDoc::plain(&s))),
|
||||
_ => Resp::Null,
|
||||
}
|
||||
}
|
||||
Req::Xform { comp, layer, t } => {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
let ctx = self.ctx_at(*comp, f64::from_bits(*t))?;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ var textTotal = 1;
|
|||
var selectorValue = [100, 100, 100];
|
||||
|
||||
// Request ops (must match runtime.rs).
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9;
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
|
||||
|
||||
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
time = t;
|
||||
|
|
@ -34,6 +34,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
|||
function __finish(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
|
||||
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
|
||||
// host requests can still be answered and the script re-run.
|
||||
if (Array.isArray(r)) return r.map(function (x) { return __v(x); });
|
||||
|
|
@ -640,6 +641,18 @@ Prop.prototype = {
|
|||
inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); },
|
||||
outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); },
|
||||
isClosed: function () { return this.value.isClosed(); },
|
||||
// Source Text style API.
|
||||
__docAt: function (t) {
|
||||
var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self);
|
||||
if (d === null) __err('Property "' + this.name + '" is not a Source Text property');
|
||||
return JSON.parse(d);
|
||||
},
|
||||
get style() { return this.getStyleAt(0); },
|
||||
getStyleAt: function (i, t) {
|
||||
if (t === undefined) t = time;
|
||||
return new __TextStyle(this.__docAt(__v(t)), i === undefined ? 0 : Math.floor(__v(i)), [], false);
|
||||
},
|
||||
createStyle: function () { return new __TextStyle(this.__docAt(time), 0, [], true); },
|
||||
valueOf: function () { return this.value; },
|
||||
toString: function () { return String(this.value); },
|
||||
};
|
||||
|
|
@ -658,6 +671,138 @@ function __mkProp(c, l, path) {
|
|||
return new Proxy(new Prop(c, l, path), __propHandler);
|
||||
}
|
||||
|
||||
// Text styles (the Source Text style API) ------------------------------------------------------
|
||||
// A TextStyle is the source document (as JSON from the host), the character index it reads its
|
||||
// attributes at, and the setter calls made on it ([key, value, start, count]). Setters return
|
||||
// new objects; returning a style from a Source Text expression applies it.
|
||||
|
||||
function __TextStyle(doc, idx, ops, empty) { this.__d = doc; this.__i = idx; this.__ops = ops; this.__empty = empty; }
|
||||
|
||||
function __styleSetter(key, conv) {
|
||||
return function (v, start, count) {
|
||||
v = __v(v);
|
||||
if (conv) v = conv(v, this);
|
||||
var s = start === undefined ? -1 : Math.max(0, Math.floor(__v(start)));
|
||||
var n = count === undefined ? -1 : Math.max(0, Math.floor(__v(count)));
|
||||
return new __TextStyle(this.__d, this.__i, this.__ops.concat([[key, v, s, n]]), this.__empty);
|
||||
};
|
||||
}
|
||||
|
||||
function __colorIn(c) {
|
||||
if (typeof c === 'string') return c;
|
||||
return [c[0], c[1], c[2]];
|
||||
}
|
||||
|
||||
__TextStyle.prototype = {
|
||||
__isTextStyle: true,
|
||||
__base: function () {
|
||||
var runs = this.__d.runs, pos = 0, i = this.__i;
|
||||
for (var k = 0; k < runs.length; k++) {
|
||||
if (i < pos + runs[k].len || k === runs.length - 1) return runs[k].style;
|
||||
pos += runs[k].len;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
__para: function () {
|
||||
var t = this.text, p = 0;
|
||||
for (var k = 0; k < Math.min(this.__i, t.length); k++) if (t[k] === '\n' || t[k] === '\r') p++;
|
||||
var paras = this.__d.paras;
|
||||
return paras[Math.min(p, paras.length - 1)];
|
||||
},
|
||||
__get: function (key, para) {
|
||||
for (var k = this.__ops.length - 1; k >= 0; k--) {
|
||||
var o = this.__ops[k];
|
||||
if (o[0] !== key) continue;
|
||||
if (o[2] < 0 || (this.__i >= o[2] && (o[3] < 0 || this.__i < o[2] + o[3]))) return o[1];
|
||||
}
|
||||
return (para ? this.__para() : this.__base())[key];
|
||||
},
|
||||
get text() {
|
||||
var t = this.__d.text;
|
||||
for (var k = 0; k < this.__ops.length; k++) {
|
||||
var o = this.__ops[k];
|
||||
if (o[0] !== 'text') continue;
|
||||
if (o[2] < 0) t = String(o[1]);
|
||||
else t = t.slice(0, o[2]) + String(o[1]) + (o[3] < 0 ? '' : t.slice(o[2] + o[3]));
|
||||
}
|
||||
return t;
|
||||
},
|
||||
get font() { return this.__get('font'); },
|
||||
get fontSize() { return this.__get('size'); },
|
||||
get isFauxBold() { return this.__get('fauxBold'); },
|
||||
get isFauxItalic() { return this.__get('fauxItalic'); },
|
||||
get isAllCaps() { return this.__get('allCaps'); },
|
||||
get isSmallCaps() { return this.__get('smallCaps'); },
|
||||
get tracking() { return this.__get('tracking'); },
|
||||
get autoLeading() { return this.__get('leading') === 'auto'; },
|
||||
get leading() { var l = this.__get('leading'); return l === 'auto' ? this.fontSize * 1.2 : l; },
|
||||
get baselineShift() { return this.__get('baselineShift'); },
|
||||
get applyFill() { return this.__get('applyFill'); },
|
||||
get fillColor() { var c = this.__get('fill'); return [c[0], c[1], c[2]]; },
|
||||
get applyStroke() { return this.__get('applyStroke'); },
|
||||
get strokeColor() { var c = this.__get('stroke'); return [c[0], c[1], c[2]]; },
|
||||
get strokeWidth() { return this.__get('strokeWidth'); },
|
||||
get horizontalScaling() { return this.__get('hScale'); },
|
||||
get verticalScaling() { return this.__get('vScale'); },
|
||||
get tsume() { return this.__get('tsume'); },
|
||||
get baselineOption() { return this.__get('baseline'); },
|
||||
get isSuperscript() { return this.__get('baseline') === 'superscript'; },
|
||||
get isSubscript() { return this.__get('baseline') === 'subscript'; },
|
||||
get kerningType() { var k = this.__get('kerning'); return typeof k === 'number' ? 'manual' : k; },
|
||||
get kerning() { var k = this.__get('kerning'); return typeof k === 'number' ? k : 0; },
|
||||
get isLigature() { return this.__get('ligatures'); },
|
||||
get justification() { return this.__get('justify', true); },
|
||||
get firstLineIndent() { return this.__get('indentFirst', true); },
|
||||
get startIndent() { return this.__get('indentLeft', true); },
|
||||
get endIndent() { return this.__get('indentRight', true); },
|
||||
get spaceBefore() { return this.__get('spaceBefore', true); },
|
||||
get spaceAfter() { return this.__get('spaceAfter', true); },
|
||||
get direction() { return this.__get('direction', true); },
|
||||
get isEveryLineComposer() { return this.__get('composer', true) === 'everyLine'; },
|
||||
get isHangingRoman() { return this.__get('hangingPunctuation', true); },
|
||||
setText: __styleSetter('text', function (v) { return String(v); }),
|
||||
replaceText: __styleSetter('text', function (v) { return String(v); }),
|
||||
setFont: __styleSetter('font', function (v) { return String(v); }),
|
||||
setFontSize: __styleSetter('size'),
|
||||
setFauxBold: __styleSetter('fauxBold', Boolean),
|
||||
setFauxItalic: __styleSetter('fauxItalic', Boolean),
|
||||
setAllCaps: __styleSetter('allCaps', Boolean),
|
||||
setSmallCaps: __styleSetter('smallCaps', Boolean),
|
||||
setTracking: __styleSetter('tracking'),
|
||||
setLeading: __styleSetter('leading'),
|
||||
setAutoLeading: __styleSetter('leading', function (v, s) { return v ? 'auto' : s.leading; }),
|
||||
setBaselineShift: __styleSetter('baselineShift'),
|
||||
setApplyFill: __styleSetter('applyFill', Boolean),
|
||||
setFillColor: __styleSetter('fill', __colorIn),
|
||||
setApplyStroke: __styleSetter('applyStroke', Boolean),
|
||||
setStrokeColor: __styleSetter('stroke', __colorIn),
|
||||
setStrokeWidth: __styleSetter('strokeWidth'),
|
||||
setHorizontalScaling: __styleSetter('hScale'),
|
||||
setVerticalScaling: __styleSetter('vScale'),
|
||||
setTsume: __styleSetter('tsume'),
|
||||
setBaselineOption: __styleSetter('baseline', function (v) { return String(v).toLowerCase().replace('_baseline', ''); }),
|
||||
setSuperscript: __styleSetter('superscript', Boolean),
|
||||
setSubscript: __styleSetter('subscript', Boolean),
|
||||
setKerningType: __styleSetter('kerning', function (v) { v = String(v).toLowerCase(); return v === 'manual' ? 0 : v; }),
|
||||
setKerning: __styleSetter('kerning', Number),
|
||||
setLigature: __styleSetter('ligatures', Boolean),
|
||||
setJustification: __styleSetter('justify', function (v) { return String(v); }),
|
||||
setFirstLineIndent: __styleSetter('indentFirst'),
|
||||
setStartIndent: __styleSetter('indentLeft'),
|
||||
setEndIndent: __styleSetter('indentRight'),
|
||||
setSpaceBefore: __styleSetter('spaceBefore'),
|
||||
setSpaceAfter: __styleSetter('spaceAfter'),
|
||||
setDirection: __styleSetter('direction', function (v) { v = String(v).toLowerCase(); return v.indexOf('right') === 0 || v === 'rtl' ? 'rtl' : 'ltr'; }),
|
||||
setEveryLineComposer: __styleSetter('composer', function (v) { return v ? 'everyLine' : 'singleLine'; }),
|
||||
setHangingRoman: __styleSetter('hangingPunctuation', Boolean),
|
||||
__out: function () {
|
||||
return { __style: true, __json: JSON.stringify({ doc: this.__empty ? null : this.__d.doc, ops: this.__ops }) };
|
||||
},
|
||||
toString: function () { return this.text; },
|
||||
valueOf: function () { return this.text; },
|
||||
};
|
||||
Object.defineProperty(__TextStyle.prototype, 'length', { get: function () { return this.text.length; } });
|
||||
|
||||
// Markers ---------------------------------------------------------------------------------------
|
||||
|
||||
function Markers(c, l) { this.__c = c; this.__l = l; this.__m = null; }
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@ pub enum Out {
|
|||
Num(f64),
|
||||
Str(String),
|
||||
Arr(Vec<Out>),
|
||||
Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool },
|
||||
Path {
|
||||
points: Vec<[f64; 2]>,
|
||||
ins: Vec<[f64; 2]>,
|
||||
outs: Vec<[f64; 2]>,
|
||||
closed: bool,
|
||||
},
|
||||
/// A text style object: JSON `{doc: TextDoc | null, ops: [[key, value, start, count], …]}`.
|
||||
Style(String),
|
||||
Object(String),
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +138,13 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
let l = num(args, 2, ctx)?;
|
||||
Req::Markers { comp: id(args, 1, ctx)?, layer: (l >= 0.0).then_some(l as u64) }
|
||||
}
|
||||
10 => Req::Doc {
|
||||
comp: id(args, 1, ctx)?,
|
||||
layer: id(args, 2, ctx)?,
|
||||
path: string(args, 3, ctx)?,
|
||||
t: secs(num(args, 4, ctx)?),
|
||||
pre: arg(args, 5).to_boolean(),
|
||||
},
|
||||
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
|
||||
};
|
||||
let found = FRAMES.with(|f| {
|
||||
|
|
@ -278,6 +292,10 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
|||
}
|
||||
return Ok(Out::Arr(out));
|
||||
}
|
||||
if o.get(js_string!("__style"), ctx).map_err(err)?.to_boolean() {
|
||||
let j = o.get(js_string!("__json"), ctx).map_err(err)?;
|
||||
return Ok(Out::Style(j.as_string().map(|s| s.to_std_string_escaped()).unwrap_or_default()));
|
||||
}
|
||||
if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() {
|
||||
let pts = |name: JsString, ctx: &mut Context| -> Result<Vec<[f64; 2]>, String> {
|
||||
let v = o.get(name, ctx).map_err(err)?;
|
||||
|
|
@ -408,6 +426,9 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::<Vec<_>>().join(","),
|
||||
Out::Undefined | Out::Null => String::new(),
|
||||
Out::Path { .. } => "[object Path]".into(),
|
||||
Out::Style(j) => {
|
||||
serde_json::from_str::<serde_json::Value>(j).ok().map(|v| styled_doc(&v, &effectcraft_keyframe::TextDoc::default()).text).unwrap_or_default()
|
||||
}
|
||||
Out::Object(s) => s.clone(),
|
||||
}
|
||||
};
|
||||
|
|
@ -428,11 +449,17 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
}
|
||||
Value::Bool(_) => Value::Bool(truthy(out)),
|
||||
Value::Enum(_) => Value::Enum((finite(numeric(out).unwrap_or(1.0))? - 1.0).round().max(0.0) as u32),
|
||||
Value::Text(doc) => {
|
||||
let mut d = doc.clone();
|
||||
d.text = text(out);
|
||||
Value::Text(d)
|
||||
}
|
||||
Value::Text(doc) => match out {
|
||||
Out::Style(j) => {
|
||||
let v: serde_json::Value = serde_json::from_str(j).map_err(|e| format!("Error: bad text style: {e}"))?;
|
||||
Value::Text(Box::new(styled_doc(&v, doc)))
|
||||
}
|
||||
_ => {
|
||||
let mut d = doc.clone();
|
||||
d.set_text(&text(out));
|
||||
Value::Text(d)
|
||||
}
|
||||
},
|
||||
Value::Str(_) => Value::Str(text(out)),
|
||||
Value::Path(_) => match out {
|
||||
Out::Path { points, ins, outs, closed } => {
|
||||
|
|
@ -450,6 +477,48 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Apply a returned text style (`{doc, ops}`) to the property's own document `own`: the style's
|
||||
/// source document (another layer's, or this one's) gives the formatting, then the setter calls
|
||||
/// apply in order. Without `setText` the text stays `own`'s.
|
||||
pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> effectcraft_keyframe::TextDoc {
|
||||
use effectcraft_keyframe::TextDoc;
|
||||
let mut d = own.clone();
|
||||
if let Some(src) = v.get("doc").filter(|s| !s.is_null()).and_then(|s| serde_json::from_value::<TextDoc>(s.clone()).ok()) {
|
||||
if src.text == own.text {
|
||||
// The same text (this layer's own style, or an identical one): every run carries over.
|
||||
d = TextDoc { box_size: own.box_size, box_pos: own.box_pos, vertical: own.vertical, ..src };
|
||||
} else {
|
||||
let first = src.style_at(0);
|
||||
d.runs.clear();
|
||||
d.apply_style_all(|s| *s = first.clone());
|
||||
let p0 = src.para(0);
|
||||
d.set_paras(vec![p0; d.para_count()]);
|
||||
d.stroke_over_fill = src.stroke_over_fill;
|
||||
}
|
||||
}
|
||||
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
|
||||
let Some(key) = op.get(0).and_then(|k| k.as_str()) else { continue };
|
||||
let val = op.get(1).cloned().unwrap_or_default();
|
||||
let start = op.get(2).and_then(|x| x.as_i64()).unwrap_or(-1);
|
||||
let count = op.get(3).and_then(|x| x.as_i64()).unwrap_or(-1);
|
||||
let n = d.char_len();
|
||||
let range = (start >= 0).then(|| {
|
||||
let a = (start as usize).min(n);
|
||||
a..if count < 0 { n } else { (a + count as usize).min(n) }
|
||||
});
|
||||
if key == "text" {
|
||||
let s = val.as_str().unwrap_or_default();
|
||||
match range {
|
||||
Some(r) => d.replace_range(r, s, None),
|
||||
None => d.set_text(s),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let _ = d.set_attr(key, &val, range);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Number → string like JS (`3`, not `3.0`).
|
||||
fn js_number(x: f64) -> String {
|
||||
if x.is_finite() && x == x.trunc() && x.abs() < 1e21 { format!("{}", x as i64) } else { format!("{x}") }
|
||||
|
|
|
|||
|
|
@ -723,6 +723,60 @@ fn source_text_reading_and_string_methods() {
|
|||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_text_style_api() {
|
||||
let mut f = fx();
|
||||
let doc = |f: &Fx, e: &str| -> TextDoc {
|
||||
match f.ev(f.title, "text/sourceText", e, 0.0) {
|
||||
Ok(Value::Text(d)) => *d,
|
||||
r => panic!("{e}: {r:?}"),
|
||||
}
|
||||
};
|
||||
// Getters read the first character's style.
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fontSize", 0.0), 40.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.isAllCaps ? 1 : 0", 0.0), 0.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.autoLeading ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.leading", 0.0), 48.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fillColor[1]", 0.0), 1.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setFontSize(12).fontSize", 0.0), 12.0);
|
||||
// Setters chain and return a styled document.
|
||||
let d = doc(&f, "text.sourceText.style.setFontSize(80).setFillColor([1, 0, 0]).setAllCaps(true)");
|
||||
assert_eq!(d.text, "Hello");
|
||||
assert!(d.is_uniform());
|
||||
assert_eq!((d.size, d.fill, d.all_caps), (80.0, [1.0, 0.0, 0.0, 1.0], true));
|
||||
// Character ranges create runs; setText replaces the text.
|
||||
let d = doc(&f, "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)");
|
||||
assert_eq!(d.text, "Hi there");
|
||||
let sizes: Vec<(usize, f64, bool)> = d.runs().iter().map(|r| (r.len, r.style.size, r.style.faux_bold)).collect();
|
||||
assert_eq!(sizes, vec![(2, 40.0, true), (1, 40.0, false), (5, 20.0, false)]);
|
||||
// getStyleAt reads a styled document per character (and at a time).
|
||||
{
|
||||
let l = f.layer_mut(f.title);
|
||||
let p = l.props.prop_mut("text/sourceText").unwrap();
|
||||
let Value::Text(d) = &mut p.value else { panic!() };
|
||||
d.apply_style(1..3, |s| s.size = 99.0);
|
||||
}
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(2, 0).fontSize", 0.0), 99.0);
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(4).fontSize", 0.0), 40.0);
|
||||
// Returning the own style keeps its runs; plain strings keep the first character's style.
|
||||
let d = doc(&f, "text.sourceText.style");
|
||||
assert_eq!(d.runs().len(), 3);
|
||||
let d = doc(&f, "'Bye'");
|
||||
assert_eq!((d.text.as_str(), d.size), ("Bye", 40.0));
|
||||
// Paragraph setters.
|
||||
let d = doc(&f, "text.sourceText.style.setJustification('CENTER_JUSTIFY').setStartIndent(12).setSpaceBefore(4).setEveryLineComposer(false)");
|
||||
assert_eq!(d.justify, effectcraft_keyframe::Justify::Center);
|
||||
assert_eq!((d.indent_left, d.space_before), (12.0, 4.0));
|
||||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setStartIndent(7).startIndent", 0.0), 7.0);
|
||||
// createStyle sets only what's given and keeps the existing runs.
|
||||
let d = doc(&f, "thisComp.layer('Title').text.sourceText.createStyle().setTsume(50).setBaselineOption('superscript')");
|
||||
assert_eq!(d.text, "Hello");
|
||||
assert!(d.runs().iter().all(|r| r.style.tsume == 50.0 && r.style.baseline == effectcraft_keyframe::BaselineOption::Superscript));
|
||||
assert_eq!(d.runs().len(), 3);
|
||||
// Non-text properties have no style.
|
||||
assert!(f.ev(f.a, ROT, "transform.rotation.style.fontSize", 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_to_source_text() {
|
||||
let f = fx();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ use crate::ops;
|
|||
/// `Renderer::comp_frame_cpu` returns it). `None` when something cannot run here (the caller
|
||||
/// renders on the CPU instead).
|
||||
pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick) -> Option<GpuImage> {
|
||||
// Region-of-interest frames are cropped on the CPU path.
|
||||
if r.opts.roi.is_some() {
|
||||
return None;
|
||||
}
|
||||
let ctx = r.eval_ctx(comp_id, t)?;
|
||||
let s = r.opts.scale;
|
||||
let w = ((ctx.comp.width as f64 * s).round() as u32).max(1);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
//! (pixels/second), evaluated through an arc-length table.
|
||||
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
|
||||
|
||||
pub mod text_doc;
|
||||
pub mod value;
|
||||
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
|
||||
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
|
||||
|
||||
/// Temporal interpolation on one side of a keyframe.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,76 +52,7 @@ impl ShapePath {
|
|||
}
|
||||
}
|
||||
|
||||
/// Horizontal paragraph alignment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
JustifyLastLeft,
|
||||
JustifyLastCenter,
|
||||
JustifyLastRight,
|
||||
JustifyAll,
|
||||
}
|
||||
|
||||
/// The value of a text layer's Source Text (hold-interpolated).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextDoc {
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub style: String,
|
||||
pub size: f64,
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
pub apply_fill: bool,
|
||||
pub apply_stroke: bool,
|
||||
pub stroke_over_fill: bool,
|
||||
pub tracking: f64,
|
||||
/// None = auto (120% of size).
|
||||
pub leading: Option<f64>,
|
||||
pub justify: Justify,
|
||||
pub baseline_shift: f64,
|
||||
pub h_scale: f64,
|
||||
pub v_scale: f64,
|
||||
pub faux_bold: bool,
|
||||
pub faux_italic: bool,
|
||||
pub all_caps: bool,
|
||||
pub small_caps: bool,
|
||||
/// Paragraph text box (width, height) with top-left at `box_pos`; None = point text.
|
||||
pub box_size: Option<[f64; 2]>,
|
||||
pub box_pos: [f64; 2],
|
||||
}
|
||||
|
||||
impl Default for TextDoc {
|
||||
fn default() -> Self {
|
||||
TextDoc {
|
||||
text: String::new(),
|
||||
font: "Inter".into(),
|
||||
style: "Regular".into(),
|
||||
size: 72.0,
|
||||
fill: [1.0, 1.0, 1.0, 1.0],
|
||||
stroke: [0.0, 0.0, 0.0, 1.0],
|
||||
stroke_width: 0.0,
|
||||
apply_fill: true,
|
||||
apply_stroke: false,
|
||||
stroke_over_fill: true,
|
||||
tracking: 0.0,
|
||||
leading: None,
|
||||
justify: Justify::Left,
|
||||
baseline_shift: 0.0,
|
||||
h_scale: 100.0,
|
||||
v_scale: 100.0,
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
all_caps: false,
|
||||
small_caps: false,
|
||||
box_size: None,
|
||||
box_pos: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use crate::text_doc::{Justify, TextDoc};
|
||||
|
||||
/// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -345,10 +276,13 @@ impl Value {
|
|||
Value::Text(t) => {
|
||||
if let Some(s) = j.as_str() {
|
||||
let mut t = (**t).clone();
|
||||
t.text = s.to_string();
|
||||
t.set_text(s);
|
||||
Some(Value::Text(Box::new(t)))
|
||||
} else {
|
||||
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
|
||||
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|mut t| {
|
||||
t.normalize();
|
||||
Value::Text(Box::new(t))
|
||||
})
|
||||
}
|
||||
}
|
||||
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
|
||||
|
|
|
|||
|
|
@ -188,11 +188,14 @@ pub struct RenderOpts {
|
|||
pub view: Option<three_d::CameraState>,
|
||||
/// CPU or GPU compositor (the GPU needs [`Renderer::accel`]).
|
||||
pub backend: Backend,
|
||||
/// 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, backend: Backend::Cpu }
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None, backend: Backend::Cpu }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +419,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;
|
||||
|
|
@ -823,12 +846,35 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
|
||||
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
|
||||
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
let r = self.opts.roi.filter(|_| self.depth == 0)?;
|
||||
let s = self.opts.scale;
|
||||
if r[2] <= 0.0 || r[3] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
|
||||
}
|
||||
|
||||
/// Scaled comp pixel → output pixel: the region of interest's offset.
|
||||
fn out_matrix(&self) -> Mat3 {
|
||||
match self.roi_offset() {
|
||||
Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)),
|
||||
None => Mat3::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer pixel → output pixel matrix for the layer at the context time.
|
||||
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let l2c = self.outer.map_or(l2c, |o| o * l2c);
|
||||
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
self.out_matrix()
|
||||
* Mat3::scale(vec2(s, s))
|
||||
* l2c
|
||||
* Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale))
|
||||
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
}
|
||||
|
||||
/// How a processed layer buffer lands in the output: one buffer → output matrix, or one per
|
||||
|
|
@ -996,7 +1042,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 mut below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
|
||||
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
|
||||
let mut below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale };
|
||||
// Effects run in the working space, not the linear blending space.
|
||||
if let Some(c) = self.pipe.from_blend() {
|
||||
color::convert(&mut below.img, &c);
|
||||
|
|
@ -1145,6 +1192,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_remap;
|
||||
#[cfg(test)]
|
||||
mod tests_roi;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
|
||||
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
|
||||
|
|
|
|||
|
|
@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
|
|||
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
|
||||
}
|
||||
|
||||
/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the
|
||||
/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents.
|
||||
pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option<Mat3> {
|
||||
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
|
||||
for sub in g.groups() {
|
||||
if sub.uid == uid {
|
||||
return Some(m);
|
||||
}
|
||||
if sub.match_id == "group"
|
||||
&& let Some(inner) = sub.sub("contents")
|
||||
{
|
||||
let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY);
|
||||
if let Some(r) = walk(ctx, layer, inner, uid, m * gm) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY)
|
||||
}
|
||||
|
||||
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
|
||||
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
//! Region of interest: a ROI render equals the matching crop of the full render.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{Image, NoFootage, RenderOpts, Renderer};
|
||||
|
||||
fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut layers = vec![];
|
||||
for (i, (c, w, h, pos, rot)) in
|
||||
[([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
|
||||
l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot);
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0);
|
||||
if i == 1 {
|
||||
l.blend_mode = BlendMode::Screen;
|
||||
l.switches.three_d = three_d;
|
||||
}
|
||||
layers.push(l);
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers = layers;
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
|
||||
let mut out = Image::new(w, h);
|
||||
for yy in 0..h {
|
||||
for xx in 0..w {
|
||||
out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_same(a: &Image, b: &Image) {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roi_render_equals_cropped_full_render() {
|
||||
for three_d in [false, true] {
|
||||
let (p, cid) = project(three_d);
|
||||
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!((roi.width, roi.height), (80, 60));
|
||||
assert_same(&roi, &crop(&full, 30, 20, 80, 60));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roi_at_half_resolution() {
|
||||
let (p, cid) = project(false);
|
||||
let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!((roi.width, roi.height), (30, 20));
|
||||
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use effectcraft_project::{Layer, PropGroup};
|
|||
use effectcraft_raster::Image;
|
||||
use effectcraft_text::path_text::{self, PathMeasure};
|
||||
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
|
||||
use effectcraft_text::{CharGlyph, TextLayout, char_glyph, layout_doc};
|
||||
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
|
|
@ -402,6 +402,8 @@ pub struct PlacedGlyph {
|
|||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
/// The character's style fills it (Character panel fill on).
|
||||
pub apply_fill: bool,
|
||||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
|
|
@ -479,14 +481,14 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
let mut subs: Vec<Option<char>> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect();
|
||||
let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3);
|
||||
let (lay, xfs) = if relayout {
|
||||
let src: Vec<char> = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect();
|
||||
let mut chars = src.clone();
|
||||
let mut chars: Vec<char> = doc.text.chars().collect();
|
||||
for (g, s) in lay.glyphs.iter().zip(&subs) {
|
||||
if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) {
|
||||
*slot = *c;
|
||||
}
|
||||
}
|
||||
let d2 = TextDoc { text: chars.into_iter().collect(), all_caps: false, ..doc.clone() };
|
||||
// Same character count: the style runs still line up.
|
||||
let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() };
|
||||
let l2 = layout_doc(&d2);
|
||||
let by_char: std::collections::HashMap<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
|
||||
let x2: Vec<CharXf> = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect();
|
||||
|
|
@ -506,11 +508,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
while li_end < n && lay.glyphs[li_end].line_index == line {
|
||||
li_end += 1;
|
||||
}
|
||||
let em = doc.size / 1000.0;
|
||||
let mut pen = 0.0;
|
||||
let (mut la, mut la_k) = (0.0, 0.0);
|
||||
for gi in li_start..li_end {
|
||||
let x = &xfs[gi];
|
||||
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
|
||||
pen += x.track_before * em;
|
||||
shift[gi] = pen;
|
||||
pen += x.track_after * em;
|
||||
|
|
@ -581,7 +583,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
}
|
||||
}
|
||||
|
||||
let base_stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
|
||||
let base = doc.base_style();
|
||||
let mut glyphs = Vec::with_capacity(n);
|
||||
for gi in 0..n {
|
||||
let g = &lay.glyphs[gi];
|
||||
|
|
@ -597,10 +599,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
x.rotation_y = 0.0;
|
||||
}
|
||||
let o = origin(gi);
|
||||
let st = lay.styles.get(g.run).unwrap_or(&base);
|
||||
let size = st.size;
|
||||
// Outline (substituted characters aligned in the original slot).
|
||||
let (outline, adv) = match subs[gi] {
|
||||
Some(c) => {
|
||||
let (p, a) = char_glyph(&doc, c);
|
||||
let (p, a) = char_glyph_style(st, c);
|
||||
let dx = match x.char_align {
|
||||
0 => 0.0,
|
||||
2 => g.advance - a,
|
||||
|
|
@ -614,8 +618,8 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
continue;
|
||||
}
|
||||
let pivot = match extents.get(&group_key(g)) {
|
||||
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * doc.size],
|
||||
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * doc.size],
|
||||
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * size],
|
||||
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * size],
|
||||
};
|
||||
// Outline relative to the pivot.
|
||||
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
|
||||
|
|
@ -635,9 +639,10 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
}
|
||||
None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m,
|
||||
};
|
||||
let fill = adjust_color(doc.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
|
||||
let stroke = adjust_color(doc.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
|
||||
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0) });
|
||||
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
|
||||
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
|
||||
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
|
||||
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill });
|
||||
}
|
||||
Some(TextGeom { doc, glyphs, fill_stroke, blend })
|
||||
}
|
||||
|
|
@ -654,21 +659,21 @@ type Pass = (usize, bool);
|
|||
fn passes(geom: &TextGeom) -> Vec<Pass> {
|
||||
let doc = &geom.doc;
|
||||
let n = geom.glyphs.len();
|
||||
let fill_on = doc.apply_fill;
|
||||
let fill_on = |i: usize| geom.glyphs[i].apply_fill;
|
||||
let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0;
|
||||
let mut v = Vec::with_capacity(n * 2);
|
||||
match geom.fill_stroke {
|
||||
1 => {
|
||||
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
|
||||
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
|
||||
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
|
||||
}
|
||||
2 => {
|
||||
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
|
||||
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
|
||||
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
|
||||
}
|
||||
_ => {
|
||||
for i in 0..n {
|
||||
let f = fill_on.then_some((i, false));
|
||||
let f = fill_on(i).then_some((i, false));
|
||||
let s = has_stroke(i).then_some((i, true));
|
||||
if doc.stroke_over_fill {
|
||||
v.extend(f);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
|
||||
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,
|
||||
//! tracking, baseline shift, all caps / small caps, underline. Results are cached.
|
||||
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14, greedy or every-line),
|
||||
//! alignment and justification, indents, paragraph spacing, leading, tracking, tsume, manual and
|
||||
//! optical kerning, baseline shift, superscript / subscript, horizontal / vertical scale, all caps
|
||||
//! / small caps, hanging punctuation, underline. Text may mix character styles (runs) and
|
||||
//! paragraph settings. Results are cached.
|
||||
//!
|
||||
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
|
||||
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
|
||||
|
|
@ -34,6 +37,19 @@ pub enum Align {
|
|||
Justify,
|
||||
}
|
||||
|
||||
/// Superscript / subscript position.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Script {
|
||||
#[default]
|
||||
Normal,
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Superscript / subscript size and baseline offset (fractions of the font size).
|
||||
pub const SCRIPT_SIZE: f32 = 0.583;
|
||||
pub const SCRIPT_OFFSET: f32 = 0.333;
|
||||
|
||||
/// Character formatting.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
|
|
@ -45,6 +61,10 @@ pub struct TextStyle {
|
|||
pub tracking: f32,
|
||||
/// Metric kerning (`kern`).
|
||||
pub kerning: bool,
|
||||
/// Optical kerning (spacing from the outlines; replaces metric kerning).
|
||||
pub optical: bool,
|
||||
/// Manual kerning before every character of the run, in 1/1000 em (replaces metrics).
|
||||
pub manual_kern: Option<f32>,
|
||||
/// Standard ligatures (`liga`, `clig`).
|
||||
pub ligatures: bool,
|
||||
/// Baseline shift in pixels (positive = up).
|
||||
|
|
@ -53,6 +73,14 @@ pub struct TextStyle {
|
|||
pub faux_italic: bool,
|
||||
pub caps: Caps,
|
||||
pub underline: bool,
|
||||
/// Horizontal / vertical scale (1 = 100%).
|
||||
pub h_scale: f32,
|
||||
pub v_scale: f32,
|
||||
/// Tsume: fraction (0–1) of the space around each glyph removed.
|
||||
pub tsume: f32,
|
||||
pub script: Script,
|
||||
/// Baseline-to-baseline distance in pixels; None = auto (120% of the size).
|
||||
pub leading: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for TextStyle {
|
||||
|
|
@ -63,26 +91,87 @@ impl Default for TextStyle {
|
|||
size: 100.0,
|
||||
tracking: 0.0,
|
||||
kerning: true,
|
||||
optical: false,
|
||||
manual_kern: None,
|
||||
ligatures: true,
|
||||
baseline_shift: 0.0,
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
caps: Caps::Normal,
|
||||
underline: false,
|
||||
h_scale: 1.0,
|
||||
v_scale: 1.0,
|
||||
tsume: 0.0,
|
||||
script: Script::Normal,
|
||||
leading: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// The size glyphs are drawn at (superscript / subscript are smaller).
|
||||
pub fn glyph_size(&self) -> f32 {
|
||||
match self.script {
|
||||
Script::Normal => self.size,
|
||||
_ => self.size * SCRIPT_SIZE,
|
||||
}
|
||||
}
|
||||
/// Upward baseline offset: baseline shift plus the superscript / subscript offset.
|
||||
pub fn rise(&self) -> f32 {
|
||||
self.baseline_shift
|
||||
+ match self.script {
|
||||
Script::Normal => 0.0,
|
||||
Script::Super => self.size * SCRIPT_OFFSET,
|
||||
Script::Sub => -self.size * SCRIPT_OFFSET,
|
||||
}
|
||||
}
|
||||
/// The line advance this style asks for.
|
||||
pub fn line_advance(&self) -> f32 {
|
||||
self.leading.unwrap_or(self.size * 1.2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph formatting.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParagraphStyle {
|
||||
pub align: Align,
|
||||
/// Extra line spacing in pixels added to the font's natural line height (Leading).
|
||||
/// How the last line of a justified paragraph is aligned (`Justify` = justify it too).
|
||||
pub justify_last: Align,
|
||||
/// Extra line spacing in pixels added to every line advance.
|
||||
pub leading: f32,
|
||||
/// Wrap width (area text); None = point text.
|
||||
/// Wrap width (area text); None = point text. Taken from the first paragraph.
|
||||
pub width: Option<f32>,
|
||||
/// Base direction: None = from the first strong character.
|
||||
pub rtl: Option<bool>,
|
||||
/// Start / end margins (left / right in left-to-right paragraphs) and the first line's indent.
|
||||
pub indent_start: f32,
|
||||
pub indent_end: f32,
|
||||
pub indent_first: f32,
|
||||
pub space_before: f32,
|
||||
pub space_after: f32,
|
||||
/// Every-line composer (balanced breaks) instead of single-line (greedy).
|
||||
pub every_line: bool,
|
||||
/// Roman hanging punctuation.
|
||||
pub hanging: bool,
|
||||
}
|
||||
|
||||
impl Default for ParagraphStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
align: Align::Left,
|
||||
justify_last: Align::Left,
|
||||
leading: 0.0,
|
||||
width: None,
|
||||
rtl: None,
|
||||
indent_start: 0.0,
|
||||
indent_end: 0.0,
|
||||
indent_first: 0.0,
|
||||
space_before: 0.0,
|
||||
space_after: 0.0,
|
||||
every_line: false,
|
||||
hanging: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hf(h: &mut impl Hasher, v: f32) {
|
||||
|
|
@ -93,19 +182,23 @@ impl Hash for TextStyle {
|
|||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.family.hash(h);
|
||||
self.style.hash(h);
|
||||
hf(h, self.size);
|
||||
hf(h, self.tracking);
|
||||
hf(h, self.baseline_shift);
|
||||
(self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);
|
||||
for v in [self.size, self.tracking, self.baseline_shift, self.h_scale, self.v_scale, self.tsume] {
|
||||
hf(h, v);
|
||||
}
|
||||
self.manual_kern.map(f32::to_bits).hash(h);
|
||||
self.leading.map(f32::to_bits).hash(h);
|
||||
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ParagraphStyle {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.align.hash(h);
|
||||
hf(h, self.leading);
|
||||
(self.align, self.justify_last).hash(h);
|
||||
for v in [self.leading, self.indent_start, self.indent_end, self.indent_first, self.space_before, self.space_after] {
|
||||
hf(h, v);
|
||||
}
|
||||
self.width.map(f32::to_bits).hash(h);
|
||||
self.rtl.hash(h);
|
||||
(self.rtl, self.every_line, self.hanging).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +214,11 @@ pub struct Glyph {
|
|||
pub cluster: usize,
|
||||
pub synth_bold: bool,
|
||||
pub synth_italic: bool,
|
||||
/// Horizontal / vertical scale of the outline (1 = 100%).
|
||||
pub h_scale: f32,
|
||||
pub v_scale: f32,
|
||||
/// Index of the style run the glyph belongs to.
|
||||
pub run: usize,
|
||||
}
|
||||
|
||||
/// One laid-out line.
|
||||
|
|
@ -137,6 +235,10 @@ pub struct Line {
|
|||
pub glyphs: Range<usize>,
|
||||
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
|
||||
pub carets: Vec<(usize, f32)>,
|
||||
/// Paragraph index.
|
||||
pub para: usize,
|
||||
/// The line ends its paragraph.
|
||||
pub para_end: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
|
@ -175,7 +277,20 @@ impl Layout {
|
|||
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
|
||||
return 0;
|
||||
};
|
||||
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
||||
self.hit_line(l, x)
|
||||
}
|
||||
/// Nearest caret byte offset to `x` on line `l`. A soft-wrapped line's end is the caret
|
||||
/// before its trailing space, so clicking past it doesn't land on the next line.
|
||||
pub fn hit_line(&self, l: &Line, x: f32) -> usize {
|
||||
let end = self.line_end(l);
|
||||
l.carets.iter().filter(|c| c.0 <= end).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
||||
}
|
||||
/// The last caret stop of a line (before a wrapped line's trailing space).
|
||||
pub fn line_end(&self, l: &Line) -> usize {
|
||||
if l.para_end {
|
||||
return l.range.end;
|
||||
}
|
||||
l.carets.iter().rev().nth(1).map(|c| c.0).filter(|b| *b >= l.range.start).unwrap_or(l.range.end)
|
||||
}
|
||||
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
|
||||
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
|
||||
|
|
@ -193,7 +308,10 @@ impl Layout {
|
|||
}
|
||||
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
|
||||
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
|
||||
if b > l.range.end {
|
||||
if x0 > x1 {
|
||||
x1 = l.x;
|
||||
}
|
||||
if b > l.range.end && l.para_end {
|
||||
x1 += l.ascent * 0.25; // selected newline
|
||||
}
|
||||
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
|
||||
|
|
@ -218,9 +336,12 @@ struct ShapedGlyph {
|
|||
id: u32,
|
||||
cluster: usize,
|
||||
adv: f32,
|
||||
/// Space added before the glyph (manual / optical kerning).
|
||||
pre: f32,
|
||||
dx: f32,
|
||||
dy: f32,
|
||||
size: f32,
|
||||
run: usize,
|
||||
}
|
||||
|
||||
struct Item {
|
||||
|
|
@ -239,7 +360,17 @@ fn upper_single(c: char) -> char {
|
|||
|
||||
const SMALL_CAPS_SCALE: f32 = 0.78;
|
||||
|
||||
fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec<ShapedGlyph> {
|
||||
/// Horizontal ink extent `(x0, x1)` of a glyph in font units.
|
||||
fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
|
||||
let p = crate::outline_units(face, gid)?;
|
||||
if p.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let r = kurbo::Shape::bounding_box(&*p);
|
||||
Some((r.x0 as f32, r.x1 as f32))
|
||||
}
|
||||
|
||||
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec<ShapedGlyph> {
|
||||
let f = fonts::face(face);
|
||||
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
|
||||
let shaper = data.shaper(&font).build();
|
||||
|
|
@ -250,7 +381,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
|
|||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let mut feats = Vec::new();
|
||||
if !style.kerning {
|
||||
if !style.kerning || style.optical || style.manual_kern.is_some() {
|
||||
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
|
||||
}
|
||||
if !style.ligatures {
|
||||
|
|
@ -259,7 +390,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
|
|||
}
|
||||
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
|
||||
let k = size / f.units_per_em();
|
||||
let _ = text;
|
||||
let hs = style.h_scale;
|
||||
out.glyph_infos()
|
||||
.iter()
|
||||
.zip(out.glyph_positions())
|
||||
|
|
@ -267,77 +398,180 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
|
|||
face,
|
||||
id: i.glyph_id,
|
||||
cluster: i.cluster as usize,
|
||||
adv: p.x_advance as f32 * k,
|
||||
dx: p.x_offset as f32 * k,
|
||||
adv: p.x_advance as f32 * k * hs,
|
||||
pre: 0.0,
|
||||
dx: p.x_offset as f32 * k * hs,
|
||||
dy: p.y_offset as f32 * k,
|
||||
size,
|
||||
run,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tsume, tracking, manual and optical kerning on one shaped item (logical order).
|
||||
fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_start: bool) {
|
||||
let side = |g: &ShapedGlyph| -> Option<(f32, f32)> {
|
||||
let (x0, x1) = ink_x(g.face, g.id)?;
|
||||
let k = g.size / fonts::face(g.face).units_per_em() * style.h_scale;
|
||||
Some((x0 * k, g.adv - x1 * k))
|
||||
};
|
||||
let is_space = |g: &ShapedGlyph| text[g.cluster..].chars().next().is_none_or(char::is_whitespace);
|
||||
if style.tsume > 0.0 {
|
||||
let t = style.tsume.clamp(0.0, 1.0);
|
||||
for g in glyphs.iter_mut() {
|
||||
if is_space(g) {
|
||||
continue;
|
||||
}
|
||||
if let Some((lsb, rsb)) = side(g) {
|
||||
g.dx -= t * lsb.max(0.0);
|
||||
g.adv -= t * (lsb.max(0.0) + rsb.max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if style.optical && glyphs.len() > 1 {
|
||||
// Pull every pair's ink gap halfway toward the item's median gap.
|
||||
let sides: Vec<Option<(f32, f32)>> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect();
|
||||
let gaps: Vec<Option<f32>> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect();
|
||||
let mut sorted: Vec<f32> = gaps.iter().flatten().copied().collect();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
if let Some(&median) = sorted.get(sorted.len() / 2) {
|
||||
for (i, gap) in gaps.iter().enumerate() {
|
||||
if let Some(gap) = gap
|
||||
&& glyphs[i + 1].cluster != glyphs[i].cluster
|
||||
{
|
||||
glyphs[i + 1].pre += (median - gap) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = style.manual_kern {
|
||||
let k = m * style.size / 1000.0;
|
||||
for i in 0..glyphs.len() {
|
||||
let first_of_cluster = i == 0 || glyphs[i - 1].cluster != glyphs[i].cluster;
|
||||
if first_of_cluster && !(para_start && glyphs[i].cluster == 0) {
|
||||
glyphs[i].pre += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
if style.tracking != 0.0 {
|
||||
let t = style.tracking * style.size / 1000.0;
|
||||
for k in 0..glyphs.len() {
|
||||
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
|
||||
glyphs[k].adv += t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ParaLine {
|
||||
range: Range<usize>,
|
||||
glyphs: Vec<Glyph>,
|
||||
carets: Vec<(usize, f32)>,
|
||||
content: f32,
|
||||
left_trim: f32,
|
||||
/// Hanging punctuation widths at the left / right edge.
|
||||
hang: (f32, f32),
|
||||
ascent: f32,
|
||||
descent: f32,
|
||||
/// Line advance (largest leading on the line).
|
||||
advance: f32,
|
||||
align: Align,
|
||||
avail: Option<f32>,
|
||||
margins: (f32, f32),
|
||||
underline: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
fn is_hanging_close(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '-' | '\u{2013}' | '\u{2014}' | '\'' | '"' | '\u{2019}' | '\u{201d}' | ')' | '\u{2026}')
|
||||
}
|
||||
fn is_hanging_open(c: char) -> bool {
|
||||
matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}')
|
||||
}
|
||||
|
||||
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
|
||||
/// paragraphs at the end).
|
||||
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
|
||||
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
|
||||
fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec<ParaLine> {
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
|
||||
let rtl_para = para.rtl == Some(true);
|
||||
let margins_for = |first: bool| -> (f32, f32) {
|
||||
let fi = if first { para.indent_first } else { 0.0 };
|
||||
if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
|
||||
};
|
||||
let avail_for = |first: bool| -> Option<f32> {
|
||||
let (l, r) = margins_for(first);
|
||||
width.map(|w| (w - l - r).max(1.0))
|
||||
};
|
||||
let style_metrics = |si: usize| {
|
||||
let st = &runs[si].1;
|
||||
fonts::face(primaries[si].face).metrics(st.glyph_size())
|
||||
};
|
||||
let eff_align = |last: bool| -> Align {
|
||||
if (last || width.is_none()) && para.align == Align::Justify {
|
||||
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
|
||||
} else {
|
||||
para.align
|
||||
}
|
||||
};
|
||||
if text.is_empty() {
|
||||
let si = run_at(runs, base);
|
||||
let pm = style_metrics(si);
|
||||
return vec![ParaLine {
|
||||
range: base..base,
|
||||
glyphs: vec![],
|
||||
carets: vec![(base, 0.0)],
|
||||
content: 0.0,
|
||||
left_trim: 0.0,
|
||||
hang: (0.0, 0.0),
|
||||
ascent: pm.ascent,
|
||||
descent: pm.descent,
|
||||
advance: runs[si].1.line_advance(),
|
||||
align: eff_align(true),
|
||||
avail: avail_for(true),
|
||||
margins: margins_for(true),
|
||||
underline: None,
|
||||
}];
|
||||
}
|
||||
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
|
||||
let bidi = BidiInfo::new(text, default_level);
|
||||
let pinfo = &bidi.paragraphs[0];
|
||||
let base_rtl = pinfo.level.is_rtl();
|
||||
// per char: (byte, char, shaped char), face, level, small
|
||||
let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text
|
||||
// per char: (byte, char, shaped char, face, rtl, small caps, run)
|
||||
let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text
|
||||
.char_indices()
|
||||
.map(|(b, c)| {
|
||||
let si = run_at(runs, base + b);
|
||||
let style = &runs[si].1;
|
||||
let (sc, small) = match style.caps {
|
||||
Caps::Normal => (c, false),
|
||||
Caps::All => (upper_single(c), false),
|
||||
Caps::Small if c.is_lowercase() => (upper_single(c), true),
|
||||
Caps::Small => (c, false),
|
||||
};
|
||||
let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) };
|
||||
(b, c, sc, face, bidi.levels[b].is_rtl(), small)
|
||||
let primary = primaries[si].face;
|
||||
let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) };
|
||||
(b, c, sc, face, bidi.levels[b].is_rtl(), small, si)
|
||||
})
|
||||
.collect();
|
||||
// items: runs of equal (face, rtl, small)
|
||||
// items: runs of equal (face, rtl, small, style)
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5);
|
||||
let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6);
|
||||
let k0 = key(&chars[i]);
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small {
|
||||
while j < chars.len() && key(&chars[j]) == k0 {
|
||||
j += 1;
|
||||
}
|
||||
let (face, rtl, small, si) = k0;
|
||||
let style = &runs[si].1;
|
||||
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
|
||||
let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size };
|
||||
let mut glyphs = shape_item(text, &sub, rtl, face, size, style);
|
||||
// tracking after each cluster
|
||||
if style.tracking != 0.0 {
|
||||
let t = style.tracking * style.size / 1000.0;
|
||||
for k in 0..glyphs.len() {
|
||||
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
|
||||
glyphs[k].adv += t;
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 };
|
||||
let mut glyphs = shape_item(&sub, rtl, face, size, style, si);
|
||||
space_item(&mut glyphs, style, text, true);
|
||||
let end = if j < chars.len() { chars[j].0 } else { text.len() };
|
||||
items.push(Item { range: chars[i].0..end, rtl, glyphs });
|
||||
i = j;
|
||||
|
|
@ -346,7 +580,7 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
let mut char_adv = vec![0.0f32; text.len() + 1];
|
||||
for it in &items {
|
||||
for g in &it.glyphs {
|
||||
char_adv[g.cluster.min(text.len())] += g.adv;
|
||||
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
|
||||
}
|
||||
}
|
||||
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
|
||||
|
|
@ -362,28 +596,83 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
}
|
||||
(end, w)
|
||||
};
|
||||
// Hanging punctuation widths (left, right) of a line's content.
|
||||
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
|
||||
if !para.hanging || width.is_none() || r.start >= ce {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let first = text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| char_adv[r.start]);
|
||||
let last = text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| char_adv[r.start + b]);
|
||||
(first, last)
|
||||
};
|
||||
let fits_w = |r: &Range<usize>| -> f32 {
|
||||
let (ce, _) = trailing_ws(r);
|
||||
let (hl, hr) = hang_of(r, ce);
|
||||
range_w(r.start..ce) - hl - hr
|
||||
};
|
||||
// line breaking
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
match para.width {
|
||||
match width {
|
||||
None => ranges.push(0..text.len()),
|
||||
Some(maxw) => {
|
||||
let mut start = 0usize;
|
||||
let mut last_ok: Option<usize> = None;
|
||||
for (p, _) in unicode_linebreak::linebreaks(text) {
|
||||
loop {
|
||||
let r = start..p;
|
||||
let (ce, _) = trailing_ws(&r);
|
||||
let w = range_w(start..ce);
|
||||
if w <= maxw || last_ok.is_none() {
|
||||
last_ok = Some(p);
|
||||
break;
|
||||
Some(_) => {
|
||||
let opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect();
|
||||
let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX);
|
||||
if para.every_line && opps.len() > 1 && opps.len() <= 1500 {
|
||||
// Minimise the summed squared slack of every line but the last.
|
||||
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
|
||||
let m = nodes.len();
|
||||
let mut best = vec![(f32::INFINITY, 0usize); m];
|
||||
best[0] = (0.0, 0);
|
||||
for j in 1..m {
|
||||
for i in (0..j).rev() {
|
||||
if !best[i].0.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let r = nodes[i]..nodes[j];
|
||||
let w = fits_w(&r);
|
||||
let maxw = avail(usize::from(i != 0));
|
||||
let over = w > maxw;
|
||||
if over && i + 1 != j {
|
||||
break;
|
||||
}
|
||||
let slack = maxw - w;
|
||||
let cost = if over {
|
||||
1e12 + w
|
||||
} else if j == m - 1 {
|
||||
0.0
|
||||
} else {
|
||||
slack * slack
|
||||
};
|
||||
let total = best[i].0 + cost;
|
||||
if total < best[j].0 {
|
||||
best[j] = (total, i);
|
||||
}
|
||||
}
|
||||
let b = last_ok.take().expect("checked");
|
||||
ranges.push(start..b);
|
||||
start = b;
|
||||
}
|
||||
let mut j = m - 1;
|
||||
while j > 0 {
|
||||
let i = best[j].1;
|
||||
ranges.push(nodes[i]..nodes[j]);
|
||||
j = i;
|
||||
}
|
||||
ranges.reverse();
|
||||
} else {
|
||||
let mut start = 0usize;
|
||||
let mut last_ok: Option<usize> = None;
|
||||
for p in opps {
|
||||
loop {
|
||||
let w = fits_w(&(start..p));
|
||||
if w <= avail(ranges.len()) || last_ok.is_none() {
|
||||
last_ok = Some(p);
|
||||
break;
|
||||
}
|
||||
let b = last_ok.take().expect("checked");
|
||||
ranges.push(start..b);
|
||||
start = b;
|
||||
}
|
||||
}
|
||||
ranges.push(start..text.len());
|
||||
}
|
||||
ranges.push(start..text.len());
|
||||
ranges.retain(|r| !r.is_empty());
|
||||
if ranges.is_empty() {
|
||||
ranges.push(0..text.len());
|
||||
|
|
@ -393,24 +682,28 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
let nlines = ranges.len();
|
||||
let mut out = Vec::new();
|
||||
for (li, r) in ranges.into_iter().enumerate() {
|
||||
let last = li + 1 == nlines;
|
||||
let align = eff_align(last);
|
||||
let avail = avail_for(li == 0);
|
||||
let (ce, tw) = trailing_ws(&r);
|
||||
let hang = hang_of(&r, ce);
|
||||
let content = range_w(r.start..ce);
|
||||
// justification
|
||||
let mut space_extra = 0.0;
|
||||
if para.align == Align::Justify
|
||||
&& li + 1 < nlines
|
||||
&& let Some(maxw) = para.width
|
||||
if align == Align::Justify
|
||||
&& let Some(maxw) = avail
|
||||
{
|
||||
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
|
||||
if spaces > 0 {
|
||||
space_extra = ((maxw - content) / spaces as f32).max(0.0);
|
||||
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
|
||||
}
|
||||
}
|
||||
let (levels, runs) = bidi.visual_runs(pinfo, r.clone());
|
||||
let (levels, vruns) = bidi.visual_runs(pinfo, r.clone());
|
||||
let mut pen = 0.0f32;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
|
||||
for run in runs {
|
||||
let mut underline: Option<(f32, f32)> = None;
|
||||
for run in vruns {
|
||||
let rtl = levels[run.start].is_rtl();
|
||||
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
|
||||
if rtl {
|
||||
|
|
@ -418,21 +711,31 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
}
|
||||
for k in idx {
|
||||
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
|
||||
let st = &runs[g.run].1;
|
||||
let x0 = pen;
|
||||
pen += g.pre;
|
||||
glyphs.push(Glyph {
|
||||
face: g.face,
|
||||
id: g.id,
|
||||
x: pen + g.dx,
|
||||
y: -g.dy - style.baseline_shift,
|
||||
y: -g.dy - st.rise(),
|
||||
size: g.size,
|
||||
cluster: base + g.cluster,
|
||||
synth_bold: primary.synth_bold || style.faux_bold,
|
||||
synth_italic: primary.synth_italic || style.faux_italic,
|
||||
synth_bold: primaries[g.run].synth_bold || st.faux_bold,
|
||||
synth_italic: primaries[g.run].synth_italic || st.faux_italic,
|
||||
h_scale: st.h_scale,
|
||||
v_scale: st.v_scale,
|
||||
run: g.run,
|
||||
});
|
||||
pen += g.adv;
|
||||
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
|
||||
pen += space_extra;
|
||||
}
|
||||
if st.underline && g.cluster < ce {
|
||||
let u = underline.get_or_insert((x0, pen));
|
||||
u.0 = u.0.min(x0);
|
||||
u.1 = u.1.max(pen);
|
||||
}
|
||||
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
|
||||
e.0 = e.0.min(x0);
|
||||
e.1 = e.1.max(pen);
|
||||
|
|
@ -475,53 +778,95 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
|
|||
carets.push((base + r.end, end_x));
|
||||
carets.sort_by_key(|c| c.0);
|
||||
carets.dedup_by_key(|c| c.0);
|
||||
let mut asc = pm.ascent;
|
||||
let mut desc = pm.descent;
|
||||
// Line metrics: the tallest face and the largest leading on the line.
|
||||
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
|
||||
let mut seen: Vec<usize> = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
if seen.is_empty() {
|
||||
seen.push(chars.first().map_or(0, |c| c.6));
|
||||
}
|
||||
for &si in &seen {
|
||||
let m = style_metrics(si);
|
||||
let rise = runs[si].1.rise();
|
||||
asc = asc.max(m.ascent + rise.max(0.0));
|
||||
desc = desc.max(m.descent - rise.min(0.0));
|
||||
adv = adv.max(runs[si].1.line_advance());
|
||||
}
|
||||
for g in &glyphs {
|
||||
if g.face != primary.face {
|
||||
if g.face != primaries[g.run].face {
|
||||
let m = fonts::face(g.face).metrics(g.size);
|
||||
asc = asc.max(m.ascent);
|
||||
desc = desc.max(m.descent);
|
||||
}
|
||||
}
|
||||
let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content };
|
||||
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
|
||||
out.push(ParaLine {
|
||||
range: base + r.start..base + r.end,
|
||||
glyphs,
|
||||
carets,
|
||||
content: content_w,
|
||||
left_trim: if base_rtl { tw } else { 0.0 },
|
||||
hang,
|
||||
ascent: asc,
|
||||
descent: desc,
|
||||
advance: adv,
|
||||
align,
|
||||
avail,
|
||||
margins: margins_for(li == 0),
|
||||
underline,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lay out `text` (paragraphs separated by `\n`).
|
||||
/// Lay out `text` (paragraphs separated by `\n`) in one style.
|
||||
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
|
||||
let primary = fonts::resolve(&style.family, &style.style);
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
|
||||
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
|
||||
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
|
||||
}
|
||||
|
||||
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
|
||||
/// one) and paragraph settings (one per `\n`-separated paragraph; the last repeats). The wrap
|
||||
/// width is the first paragraph's.
|
||||
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
|
||||
let fallback = [(0..text.len(), TextStyle::default())];
|
||||
let runs = if runs.is_empty() { &fallback[..] } else { runs };
|
||||
let default_para = ParagraphStyle::default();
|
||||
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
|
||||
let width = para_at(0).width;
|
||||
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
|
||||
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
|
||||
let mut base = 0usize;
|
||||
let mut plines = Vec::new();
|
||||
for p in text.split('\n') {
|
||||
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
|
||||
for (pi, p) in text.split('\n').enumerate() {
|
||||
let p_clean = p.strip_suffix('\r').unwrap_or(p);
|
||||
plines.extend(paragraph(p_clean, base, style, para, primary));
|
||||
plines.extend(paragraph(p_clean, base, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l)));
|
||||
base += p.len() + 1;
|
||||
}
|
||||
let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 };
|
||||
for (i, pl) in plines.into_iter().enumerate() {
|
||||
let baseline = first_baseline + line_h * i as f32;
|
||||
let shift = match (para.width, para.align) {
|
||||
(None, Align::Left | Align::Justify) => 0.0,
|
||||
(None, Align::Center) => -pl.content / 2.0,
|
||||
(None, Align::Right) => -pl.content,
|
||||
(Some(_), Align::Left | Align::Justify) => 0.0,
|
||||
(Some(w), Align::Center) => (w - pl.content) / 2.0,
|
||||
(Some(w), Align::Right) => w - pl.content,
|
||||
} - pl.left_trim;
|
||||
let mut baseline = 0.0f32;
|
||||
for (i, (pi, pl)) in plines.into_iter().enumerate() {
|
||||
let para = para_at(pi);
|
||||
if i == 0 {
|
||||
baseline = if width.is_some() { pl.ascent } else { 0.0 };
|
||||
} else {
|
||||
let prev_para = lay.lines.last().map_or(pi, |l| l.para);
|
||||
let mut adv = pl.advance + para.leading;
|
||||
if prev_para != pi {
|
||||
adv += para_at(prev_para).space_after + para.space_before;
|
||||
}
|
||||
baseline += adv;
|
||||
}
|
||||
let shown = pl.content - pl.hang.0 - pl.hang.1;
|
||||
let (ml, mr) = pl.margins;
|
||||
let shift = match (pl.avail, pl.align) {
|
||||
(None, Align::Left | Align::Justify) => ml,
|
||||
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
|
||||
(None, Align::Right) => -shown - mr,
|
||||
(Some(_), Align::Left | Align::Justify) => ml,
|
||||
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
|
||||
(Some(a), Align::Right) => ml + a - shown,
|
||||
} - pl.left_trim
|
||||
- pl.hang.0;
|
||||
let g0 = lay.glyphs.len();
|
||||
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
|
||||
g.x += shift;
|
||||
|
|
@ -529,8 +874,10 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
|
|||
g
|
||||
}));
|
||||
let x = shift + pl.left_trim;
|
||||
if style.underline && pl.content > 0.0 {
|
||||
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
|
||||
if let Some((u0, u1)) = pl.underline {
|
||||
let si = run_at(runs, pl.range.start);
|
||||
let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size);
|
||||
lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]);
|
||||
}
|
||||
lay.lines.push(Line {
|
||||
range: pl.range,
|
||||
|
|
@ -541,15 +888,20 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
|
|||
descent: pl.descent,
|
||||
glyphs: g0..lay.glyphs.len(),
|
||||
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
|
||||
para: pi,
|
||||
para_end: false,
|
||||
});
|
||||
}
|
||||
for i in 0..lay.lines.len() {
|
||||
lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para);
|
||||
}
|
||||
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
|
||||
for l in &lay.lines {
|
||||
x0 = x0.min(l.x);
|
||||
x1 = x1.max(l.x + l.width);
|
||||
}
|
||||
if let Some(w) = para.width {
|
||||
x0 = 0.0;
|
||||
if let Some(w) = width {
|
||||
x0 = x0.min(0.0);
|
||||
x1 = x1.max(w);
|
||||
}
|
||||
let first = &lay.lines[0];
|
||||
|
|
@ -563,12 +915,17 @@ fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
|
|||
C.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Lay out `text` (cached by text and styles).
|
||||
/// Lay out `text` in one style (cached by text and styles).
|
||||
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
|
||||
layout_rich(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
|
||||
}
|
||||
|
||||
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
|
||||
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
style.hash(&mut h);
|
||||
para.hash(&mut h);
|
||||
runs.hash(&mut h);
|
||||
paras.hash(&mut h);
|
||||
let key = h.finish();
|
||||
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
|
@ -576,7 +933,7 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layou
|
|||
e.0 = now;
|
||||
return e.1.clone();
|
||||
}
|
||||
let l = Arc::new(layout_uncached(text, style, para));
|
||||
let l = Arc::new(layout_rich_uncached(text, runs, paras));
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() >= 512 {
|
||||
// evict the oldest quarter
|
||||
|
|
@ -720,4 +1077,135 @@ mod tests {
|
|||
assert_eq!(l.caret(0).0, 0.0);
|
||||
assert!(l.bounds[3] > l.bounds[1]);
|
||||
}
|
||||
|
||||
fn rich(text: &str, runs: &[(Range<usize>, TextStyle)], para: ParagraphStyle) -> Layout {
|
||||
layout_rich_uncached(text, runs, &[para])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() {
|
||||
let text = "small BIG\nnext";
|
||||
let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))];
|
||||
let l = rich(text, &runs, ParagraphStyle::default());
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
let y0 = l.glyphs[0].y;
|
||||
assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line");
|
||||
assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0);
|
||||
// Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run.
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline);
|
||||
assert!(l.lines[0].ascent > 60.0);
|
||||
// A big run on the second line pushes it down by its 120% leading.
|
||||
let text2 = "aa\nBB";
|
||||
let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default());
|
||||
assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3);
|
||||
// Explicit leading wins over auto.
|
||||
let mut fixed = st(20.0);
|
||||
fixed.leading = Some(50.0);
|
||||
let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default());
|
||||
assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_subscript_tsume_and_kerning_metrics() {
|
||||
let base = st(100.0);
|
||||
let sup = TextStyle { script: Script::Super, ..base.clone() };
|
||||
let sub = TextStyle { script: Script::Sub, ..base.clone() };
|
||||
let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
|
||||
let g = &l.glyphs;
|
||||
assert!((g[1].size - 58.3).abs() < 0.1);
|
||||
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");
|
||||
assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered");
|
||||
// Tsume removes side bearings; full tsume is narrower than none.
|
||||
let w0 = measure("onion", &base);
|
||||
let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() });
|
||||
assert!(w1 < w0 - 5.0, "{w1} vs {w0}");
|
||||
// Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off.
|
||||
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() });
|
||||
let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() });
|
||||
assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}");
|
||||
let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() });
|
||||
assert!(optical > 0.0 && (optical - nokern).abs() < 100.0);
|
||||
// Horizontal scale stretches advances.
|
||||
let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() });
|
||||
assert!((wide - 2.0 * w0).abs() < 0.5);
|
||||
let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default());
|
||||
assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_and_paragraph_spacing() {
|
||||
let s = st(20.0);
|
||||
let text = "first paragraph here\nsecond one";
|
||||
let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() };
|
||||
let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() };
|
||||
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]);
|
||||
assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x);
|
||||
assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width);
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3);
|
||||
assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1));
|
||||
assert!(l.lines[0].para_end && l.lines[1].para_end);
|
||||
// Wrapped lines after the first don't get the first-line indent.
|
||||
let long = "word ".repeat(30);
|
||||
let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]);
|
||||
assert!(l.lines.len() > 2);
|
||||
assert!((l.lines[1].x - 30.0).abs() < 1e-3);
|
||||
assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5));
|
||||
assert!(!l.lines[0].para_end);
|
||||
// Point text: a right-aligned paragraph's end indent moves it left of the origin.
|
||||
let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]);
|
||||
assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justify_last_line_modes_and_every_line_composer() {
|
||||
let text = "the quick brown fox jumps over the lazy dog and keeps running far";
|
||||
let s = st(30.0);
|
||||
let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() };
|
||||
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
|
||||
let n = left.lines.len();
|
||||
assert!(n >= 3);
|
||||
assert!((left.lines[0].width - 320.0).abs() < 0.5);
|
||||
assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3);
|
||||
let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]);
|
||||
let lc = ¢er.lines[n - 1];
|
||||
assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5);
|
||||
let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]);
|
||||
let lr = &right.lines[n - 1];
|
||||
assert!((lr.x + lr.width - 320.0).abs() < 0.5);
|
||||
let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]);
|
||||
assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line");
|
||||
// Every-line composer: never worse (summed squared slack) than greedy breaking.
|
||||
let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() };
|
||||
let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]);
|
||||
let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]);
|
||||
assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy));
|
||||
assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hanging_punctuation_overhangs_the_box() {
|
||||
let s = st(40.0);
|
||||
let text = "\"Hi.\"";
|
||||
let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() };
|
||||
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
|
||||
assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x);
|
||||
let r = layout_rich_uncached(
|
||||
text,
|
||||
&[(0..text.len(), s)],
|
||||
&[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }],
|
||||
);
|
||||
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_carets_run_right_to_left() {
|
||||
let text = "\u{5d0}\u{5d1}\u{5d2}";
|
||||
let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]);
|
||||
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
|
||||
assert_eq!(xs.len(), 4);
|
||||
assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}");
|
||||
// Hit testing returns the logical position nearest the point.
|
||||
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
|
||||
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ pub mod path_text;
|
|||
pub mod selectors;
|
||||
pub mod sfnt;
|
||||
|
||||
pub use kurbo;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::{Justify, TextDoc};
|
||||
use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, TextDoc};
|
||||
pub use fonts::{FaceId, Resolved, families, resolve};
|
||||
use kurbo::{Affine, BezPath, Point};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout as layout_text, measure};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
|
@ -43,7 +45,7 @@ impl OutlinePen for Pen {
|
|||
}
|
||||
|
||||
/// Glyph outline in font units (y up), cached.
|
||||
fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = OnceLock::new();
|
||||
let c = C.get_or_init(Default::default);
|
||||
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
|
||||
|
|
@ -64,12 +66,14 @@ fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
|||
v
|
||||
}
|
||||
|
||||
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down).
|
||||
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its
|
||||
/// horizontal / vertical scale and faux italic.
|
||||
pub fn glyph_outline(g: &Glyph) -> BezPath {
|
||||
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
|
||||
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
|
||||
let slant = if g.synth_italic { 0.21 } else { 0.0 };
|
||||
Affine::new([k, 0.0, slant * k, -k, 0.0, 0.0]) * (*units).clone()
|
||||
let (hs, vs) = (g.h_scale as f64, g.v_scale as f64);
|
||||
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone()
|
||||
}
|
||||
|
||||
/// One character of laid-out text.
|
||||
|
|
@ -89,7 +93,10 @@ pub struct CharGlyph {
|
|||
/// The source character (after All Caps).
|
||||
pub ch: char,
|
||||
pub synth_bold: bool,
|
||||
/// The glyph's drawn size (superscript / small caps are smaller than the run's size).
|
||||
pub size: f64,
|
||||
/// Index into [`TextLayout::styles`].
|
||||
pub run: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -101,27 +108,111 @@ pub struct TextLayout {
|
|||
pub lines: usize,
|
||||
/// Bounds in layer space (x0, y0, x1, y1).
|
||||
pub bounds: [f64; 4],
|
||||
/// Line baselines (layer space) and widths for the viewer's text cursor.
|
||||
/// Line origins (layer space) `[x, baseline, width, height]`.
|
||||
pub line_boxes: Vec<[f64; 4]>,
|
||||
/// The character style of each run (glyphs refer to them by index).
|
||||
pub styles: Vec<CharStyle>,
|
||||
/// The paragraph layout (layout space) and its map into layer space.
|
||||
pub layout: Arc<Layout>,
|
||||
pub to_layer: Affine,
|
||||
/// Byte offset of every character, plus the text length.
|
||||
pub byte_of_char: Vec<usize>,
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
/// A character style → [`TextStyle`].
|
||||
pub fn text_style(s: &CharStyle) -> TextStyle {
|
||||
TextStyle {
|
||||
family: s.font.clone(),
|
||||
style: s.style.clone(),
|
||||
size: s.size as f32,
|
||||
tracking: s.tracking as f32,
|
||||
kerning: true,
|
||||
optical: s.kerning == Kerning::Optical,
|
||||
manual_kern: if let Kerning::Manual(v) = s.kerning { Some(v as f32) } else { None },
|
||||
ligatures: s.ligatures,
|
||||
baseline_shift: s.baseline_shift as f32,
|
||||
faux_bold: s.faux_bold,
|
||||
faux_italic: s.faux_italic,
|
||||
caps: if s.all_caps {
|
||||
Caps::All
|
||||
} else if s.small_caps {
|
||||
Caps::Small
|
||||
} else {
|
||||
Caps::Normal
|
||||
},
|
||||
underline: false,
|
||||
h_scale: (s.h_scale / 100.0) as f32,
|
||||
v_scale: (s.v_scale / 100.0) as f32,
|
||||
tsume: (s.tsume / 100.0) as f32,
|
||||
script: match s.baseline {
|
||||
BaselineOption::Normal => Script::Normal,
|
||||
BaselineOption::Superscript => Script::Super,
|
||||
BaselineOption::Subscript => Script::Sub,
|
||||
},
|
||||
leading: s.leading.map(|l| l as f32),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph settings → [`ParagraphStyle`] (wrap `width` for paragraph text).
|
||||
pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> ParagraphStyle {
|
||||
let (align, justify_last) = match p.justify {
|
||||
Justify::Left => (Align::Left, Align::Left),
|
||||
Justify::Center => (Align::Center, Align::Center),
|
||||
Justify::Right => (Align::Right, Align::Right),
|
||||
Justify::JustifyLastLeft => (Align::Justify, Align::Left),
|
||||
Justify::JustifyLastCenter => (Align::Justify, Align::Center),
|
||||
Justify::JustifyLastRight => (Align::Justify, Align::Right),
|
||||
Justify::JustifyAll => (Align::Justify, Align::Justify),
|
||||
};
|
||||
ParagraphStyle {
|
||||
align,
|
||||
justify_last,
|
||||
leading: 0.0,
|
||||
width,
|
||||
// Left-to-right paragraphs keep the Unicode default (first strong character).
|
||||
rtl: (p.direction == Direction::Rtl).then_some(true),
|
||||
indent_start: p.indent_left as f32,
|
||||
indent_end: p.indent_right as f32,
|
||||
indent_first: p.indent_first as f32,
|
||||
space_before: p.space_before as f32,
|
||||
space_after: p.space_after as f32,
|
||||
every_line: p.composer == Composer::EveryLine,
|
||||
hanging: p.hanging_punctuation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns
|
||||
/// running right to left), characters kept upright.
|
||||
const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
|
||||
/// Lay out a Source Text value in layer space. Point text: the origin is the start of the first
|
||||
/// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from
|
||||
/// `box_pos`.
|
||||
/// `box_pos`. Vertical text runs down from the origin (point) or the box's top-right corner.
|
||||
pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() };
|
||||
let (style, para) = styles(doc);
|
||||
let lay = layout_text(&text, &style, ¶);
|
||||
let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0);
|
||||
// Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0.
|
||||
let (dx, dy) = match doc.box_size {
|
||||
Some(_) => (doc.box_pos[0], doc.box_pos[1]),
|
||||
// The layout already pivots point text around x = 0 by alignment.
|
||||
None => (0.0, -first_base),
|
||||
let text = doc.text.as_str();
|
||||
let byte_of_char: Vec<usize> = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect();
|
||||
let nchars = byte_of_char.len() - 1;
|
||||
let mut runs = Vec::new();
|
||||
let mut styles = Vec::new();
|
||||
let mut ci = 0;
|
||||
for r in doc.runs() {
|
||||
let a = byte_of_char[ci.min(nchars)];
|
||||
ci += r.len;
|
||||
let b = byte_of_char[ci.min(nchars)];
|
||||
runs.push((a..b, text_style(&r.style)));
|
||||
styles.push(r.style);
|
||||
}
|
||||
let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32);
|
||||
let paras: Vec<ParagraphStyle> = doc.paras().iter().map(|p| paragraph_style(p, width)).collect();
|
||||
let lay = layout::layout_rich(text, &runs, ¶s);
|
||||
let to_layer = match (doc.vertical, doc.box_size) {
|
||||
(false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])),
|
||||
(false, None) => Affine::IDENTITY,
|
||||
(true, Some(b)) => Affine::translate((doc.box_pos[0] + b[0], doc.box_pos[1])) * ROT90,
|
||||
(true, None) => ROT90,
|
||||
};
|
||||
let hs = doc.h_scale / 100.0;
|
||||
let vs = doc.v_scale / 100.0;
|
||||
let mut out = TextLayout { lines: lay.lines.len(), ..Default::default() };
|
||||
let mut out = TextLayout { lines: lay.lines.len(), to_layer, vertical: doc.vertical, ..Default::default() };
|
||||
// Map byte clusters → char/word indices.
|
||||
let mut char_of_byte = vec![0usize; text.len() + 1];
|
||||
let mut word_of_char = Vec::new();
|
||||
|
|
@ -144,22 +235,30 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
|||
nospace += 1;
|
||||
}
|
||||
}
|
||||
out.chars = text.chars().count();
|
||||
char_of_byte[text.len()] = nchars;
|
||||
out.chars = nchars;
|
||||
out.chars_no_space = nospace;
|
||||
out.words = words;
|
||||
let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
|
||||
for (li, line) in lay.lines.iter().enumerate() {
|
||||
out.line_boxes.push([line.x as f64 + dx, line.baseline as f64 + dy, line.width as f64, (line.ascent + line.descent) as f64]);
|
||||
let o = to_layer * Point::new(line.x as f64, line.baseline as f64);
|
||||
out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]);
|
||||
for gi in line.glyphs.clone() {
|
||||
let g = &lay.glyphs[gi];
|
||||
let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0);
|
||||
let ch = text[g.cluster..].chars().next().unwrap_or(' ');
|
||||
let src = text[g.cluster..].chars().next().unwrap_or(' ');
|
||||
let st = styles.get(g.run);
|
||||
let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src };
|
||||
let mut path = glyph_outline(g);
|
||||
if hs != 1.0 || vs != 1.0 {
|
||||
path = Affine::scale_non_uniform(hs, vs) * path;
|
||||
}
|
||||
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
|
||||
let origin = Point::new(g.x as f64 * hs + dx, g.y as f64 + dy);
|
||||
let advance = (next_x - g.x) as f64;
|
||||
if doc.vertical {
|
||||
// Keep the character upright around its centre.
|
||||
let c = Point::new(advance / 2.0, -0.35 * g.size as f64);
|
||||
let rc = ROT90 * c;
|
||||
path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path;
|
||||
}
|
||||
let origin = to_layer * Point::new(g.x as f64, g.y as f64);
|
||||
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
|
||||
b[0] = b[0].min(origin.x + r.x0);
|
||||
b[1] = b[1].min(origin.y + r.y0);
|
||||
|
|
@ -169,62 +268,102 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
|||
out.glyphs.push(CharGlyph {
|
||||
path,
|
||||
origin,
|
||||
advance: (next_x - g.x) as f64 * hs,
|
||||
advance,
|
||||
char_index: ci,
|
||||
char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0),
|
||||
word_index: word_of_char.get(ci).copied().unwrap_or(0),
|
||||
line_index: li,
|
||||
is_space: ch.is_whitespace(),
|
||||
is_space: src.is_whitespace(),
|
||||
ch,
|
||||
synth_bold: g.synth_bold,
|
||||
size: g.size as f64,
|
||||
run: g.run,
|
||||
});
|
||||
}
|
||||
}
|
||||
if b[0].is_finite() {
|
||||
out.bounds = b;
|
||||
}
|
||||
out.styles = styles;
|
||||
out.byte_of_char = byte_of_char;
|
||||
out.layout = lay;
|
||||
out
|
||||
}
|
||||
|
||||
/// Character and paragraph styles of a Source Text value.
|
||||
fn styles(doc: &TextDoc) -> (TextStyle, ParagraphStyle) {
|
||||
let style = TextStyle {
|
||||
family: doc.font.clone(),
|
||||
style: doc.style.clone(),
|
||||
size: doc.size as f32,
|
||||
tracking: doc.tracking as f32,
|
||||
baseline_shift: doc.baseline_shift as f32,
|
||||
faux_bold: doc.faux_bold,
|
||||
faux_italic: doc.faux_italic,
|
||||
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
|
||||
..Default::default()
|
||||
};
|
||||
let align = match doc.justify {
|
||||
Justify::Left | Justify::JustifyLastLeft => Align::Left,
|
||||
Justify::Center | Justify::JustifyLastCenter => Align::Center,
|
||||
Justify::Right | Justify::JustifyLastRight => Align::Right,
|
||||
Justify::JustifyAll => Align::Justify,
|
||||
};
|
||||
let natural = doc.size * 1.2;
|
||||
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
|
||||
(style, ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None })
|
||||
impl TextLayout {
|
||||
/// Character index of byte offset `b`.
|
||||
pub fn char_of_byte(&self, b: usize) -> usize {
|
||||
self.byte_of_char.partition_point(|x| *x < b).min(self.chars)
|
||||
}
|
||||
fn byte(&self, ci: usize) -> usize {
|
||||
self.byte_of_char.get(ci.min(self.chars)).copied().unwrap_or(0)
|
||||
}
|
||||
/// Layout line holding caret `ci`.
|
||||
pub fn line_of_char(&self, ci: usize) -> usize {
|
||||
self.layout.line_of(self.byte(ci))
|
||||
}
|
||||
/// The caret at character boundary `ci` as a segment (top, bottom) in layer space.
|
||||
pub fn caret(&self, ci: usize) -> (Point, Point) {
|
||||
let (x, base, li) = self.layout.caret(self.byte(ci));
|
||||
let (asc, desc) = self.layout.lines.get(li).map_or((10.0, 3.0), |l| (l.ascent, l.descent));
|
||||
(self.to_layer * Point::new(x as f64, (base - asc) as f64), self.to_layer * Point::new(x as f64, (base + desc) as f64))
|
||||
}
|
||||
/// The caret's position along its line (layout space), for up / down movement.
|
||||
pub fn caret_x(&self, ci: usize) -> f64 {
|
||||
self.layout.caret(self.byte(ci)).0 as f64
|
||||
}
|
||||
/// The character boundary nearest a layer-space point.
|
||||
pub fn hit(&self, p: Point) -> usize {
|
||||
let q = self.to_layer.inverse() * p;
|
||||
self.char_of_byte(self.layout.hit(q.x as f32, q.y as f32))
|
||||
}
|
||||
/// The caret one line up (`dir` < 0) or down from `ci`, keeping the line position `goal`.
|
||||
/// Past the first / last line it goes to the start / end of the text.
|
||||
pub fn move_vertical(&self, ci: usize, dir: i32, goal: Option<f64>) -> (usize, f64) {
|
||||
let goal = goal.unwrap_or_else(|| self.caret_x(ci));
|
||||
let li = self.line_of_char(ci) as i64 + dir as i64;
|
||||
if li < 0 {
|
||||
return (0, goal);
|
||||
}
|
||||
match self.layout.lines.get(li as usize) {
|
||||
Some(l) => (self.char_of_byte(self.layout.hit_line(l, goal as f32)), goal),
|
||||
None => (self.chars, goal),
|
||||
}
|
||||
}
|
||||
/// Start and end carets of the visual line holding `ci`.
|
||||
pub fn line_span(&self, ci: usize) -> (usize, usize) {
|
||||
let li = self.line_of_char(ci);
|
||||
match self.layout.lines.get(li) {
|
||||
Some(l) => (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l))),
|
||||
None => (0, self.chars),
|
||||
}
|
||||
}
|
||||
/// Selection highlight quads (layer space) for characters `a..b`.
|
||||
pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||||
self.layout
|
||||
.selection_rects(self.byte(a.min(b)), self.byte(a.max(b)))
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let m = |x: f32, y: f32| self.to_layer * Point::new(x as f64, y as f64);
|
||||
[m(r[0], r[1]), m(r[2], r[1]), m(r[2], r[3]), m(r[0], r[3])]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline (origin on the baseline) and advance of a single character in a Source Text's
|
||||
/// style: used by Character Offset / Character Value substitutions.
|
||||
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
|
||||
let (style, _) = styles(doc);
|
||||
/// Outline (origin on the baseline) and advance of a single character in a character style:
|
||||
/// used by Character Offset / Character Value substitutions.
|
||||
pub fn char_glyph_style(s: &CharStyle, ch: char) -> (BezPath, f64) {
|
||||
let style = text_style(s);
|
||||
let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default());
|
||||
let hs = doc.h_scale / 100.0;
|
||||
let vs = doc.v_scale / 100.0;
|
||||
let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) };
|
||||
let mut path = glyph_outline(g);
|
||||
if hs != 1.0 || vs != 1.0 {
|
||||
path = Affine::scale_non_uniform(hs, vs) * path;
|
||||
}
|
||||
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0) * hs;
|
||||
(Affine::translate((-(g.x as f64) * hs, 0.0)) * path, adv)
|
||||
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0);
|
||||
(Affine::translate((-(g.x as f64), 0.0)) * glyph_outline(g), adv)
|
||||
}
|
||||
|
||||
/// [`char_glyph_style`] in a Source Text's base style.
|
||||
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
|
||||
char_glyph_style(&doc.base_style(), ch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -244,6 +383,61 @@ mod tests {
|
|||
assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_runs_carets_and_hit_testing() {
|
||||
let mut doc = TextDoc { text: "Hello big\nworld".into(), size: 40.0, ..Default::default() };
|
||||
doc.apply_style(6..9, |s| {
|
||||
s.size = 80.0;
|
||||
s.fill = [1.0, 0.0, 0.0, 1.0];
|
||||
});
|
||||
let l = layout_doc(&doc);
|
||||
let g = l.glyphs.iter().find(|g| g.char_index == 7).unwrap();
|
||||
assert_eq!(l.styles[g.run].size, 80.0);
|
||||
assert_eq!(l.styles[g.run].fill, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(g.size, 80.0);
|
||||
let small = l.glyphs.iter().find(|g| g.char_index == 1).unwrap();
|
||||
assert_eq!(small.origin.y, g.origin.y, "one baseline for mixed sizes");
|
||||
// Line 2 sits one 40 px auto leading (48 px) below line 1.
|
||||
let w = l.glyphs.iter().find(|g| g.char_index == 10).unwrap();
|
||||
assert!((w.origin.y - g.origin.y - 48.0).abs() < 1e-6);
|
||||
// Every caret hit-tests back to itself; carets advance along each line.
|
||||
for ci in 0..=doc.char_len() {
|
||||
let (a, b) = l.caret(ci);
|
||||
assert!(b.y > a.y);
|
||||
assert_eq!(l.hit(a.midpoint(b)), ci, "caret {ci}");
|
||||
}
|
||||
assert!(l.caret(8).0.x > l.caret(7).0.x);
|
||||
// Up / down keep the line position; line spans and selections.
|
||||
let (down, goal) = l.move_vertical(2, 1, None);
|
||||
assert_eq!(l.line_of_char(down), 1);
|
||||
assert!((goal - l.caret_x(2)).abs() < 1e-6);
|
||||
assert_eq!(l.move_vertical(down, -1, Some(goal)).0, 2);
|
||||
assert_eq!(l.move_vertical(2, -1, None).0, 0);
|
||||
assert_eq!(l.move_vertical(12, 1, None).0, 15);
|
||||
assert_eq!(l.line_span(3), (0, 9));
|
||||
assert_eq!(l.line_span(12), (10, 15));
|
||||
let q = l.selection_quads(3, 12);
|
||||
assert_eq!(q.len(), 2);
|
||||
assert_eq!(l.char_of_byte(doc.byte_of(12)), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_box_offset_and_vertical_type() {
|
||||
let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() };
|
||||
let l = layout_doc(&doc);
|
||||
let (a, _) = l.caret(0);
|
||||
assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}");
|
||||
// Vertical: characters run down a column, carets are horizontal.
|
||||
let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() });
|
||||
let ys: Vec<f64> = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect();
|
||||
assert!(ys.windows(2).all(|w| w[1] > w[0]), "{ys:?}");
|
||||
let col2 = v.glyphs.iter().find(|g| g.line_index == 1).unwrap();
|
||||
assert!(col2.origin.x < v.glyphs[0].origin.x - 10.0, "next column to the left");
|
||||
let (p0, p1) = v.caret(1);
|
||||
assert!((p0.y - p1.y).abs() < 1e-6 && (p0.x - p1.x).abs() > 5.0);
|
||||
assert_eq!(v.hit(p0.midpoint(p1)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_point_text_straddles_origin() {
|
||||
let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ pub struct RenderSource {
|
|||
pub layer_cache: Arc<LayerCache>,
|
||||
/// GPU compositor on the viewer's device (Mercury GPU Acceleration), if available.
|
||||
pub gpu: Option<Gpu>,
|
||||
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off by default:
|
||||
/// Show Channel / exposure / region-of-interest drawing read the CPU frame texture, so GPU
|
||||
/// frames are read back (the compositing still runs on the GPU).
|
||||
pub gpu_display: bool,
|
||||
}
|
||||
|
||||
pub struct Frames {
|
||||
|
|
@ -328,7 +332,7 @@ impl Worker {
|
|||
// The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no
|
||||
// adapter, or a frame the GPU cannot finish here) the CPU renders it.
|
||||
let gpu_frame = match (&job.src.gpu, r.active_accel()) {
|
||||
(Some(g), Some(_)) => g.render_display(&r, job.comp, job.t),
|
||||
(Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t),
|
||||
_ => None,
|
||||
};
|
||||
let ci = match gpu_frame {
|
||||
|
|
|
|||
|
|
@ -113,9 +113,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
x = puppet_options(app, ui, &p, x, cy);
|
||||
}
|
||||
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
|
||||
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
|
||||
app.ui.snapping = app.session.state.snapping;
|
||||
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
|
||||
app.ui.snapping = !app.ui.snapping;
|
||||
app.session.state.snapping = app.ui.snapping;
|
||||
let _ = app.session.execute("view.snapping", serde_json::json!({}));
|
||||
app.ui.snapping = app.session.state.snapping;
|
||||
}
|
||||
app.auto.add("header.snapping", snap, "Snapping");
|
||||
p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ pub enum Icon {
|
|||
Polygon,
|
||||
Star,
|
||||
Pen,
|
||||
PenAdd,
|
||||
PenDelete,
|
||||
PenConvert,
|
||||
MaskFeather,
|
||||
Type,
|
||||
TypeVertical,
|
||||
Brush,
|
||||
|
|
@ -557,6 +561,25 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
Sparkle => {
|
||||
pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]);
|
||||
}
|
||||
PenAdd | PenDelete => {
|
||||
// A small pen nib with a plus or minus beside it.
|
||||
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
|
||||
pen.line(&[(6.0, 2.5), (6.0, 7.0)]);
|
||||
pen.line(&[(10.0, 11.5), (14.5, 11.5)]);
|
||||
if icon == PenAdd {
|
||||
pen.line(&[(12.25, 9.25), (12.25, 13.75)]);
|
||||
}
|
||||
}
|
||||
PenConvert => {
|
||||
// An open caret: a corner vertex with two straight sides.
|
||||
pen.line(&[(2.5, 13.0), (8.0, 3.5), (13.5, 13.0)]);
|
||||
pen.dot(8.0, 3.5, 1.4);
|
||||
}
|
||||
MaskFeather => {
|
||||
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
|
||||
pen.line(&[(6.0, 2.5), (6.0, 7.0)]);
|
||||
pen.line(&[(10.5, 4.0), (12.5, 6.5), (13.5, 10.0), (12.5, 13.5)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ impl EffectcraftApp {
|
|||
expr: self.session.expr.clone(),
|
||||
layer_cache: self.session.layer_cache.clone(),
|
||||
gpu: self.gpu.clone(),
|
||||
gpu_display: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,7 +343,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 {
|
||||
|
|
@ -352,8 +359,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);
|
||||
|
|
@ -378,12 +390,15 @@ 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 (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: self.ui.viewer.fast_preview || draft,
|
||||
view: self.session.view_camera(comp),
|
||||
roi,
|
||||
backend: effectcraft_engine::render::Backend::Auto,
|
||||
};
|
||||
if urgent {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -231,11 +231,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
|
||||
// Show Reference Graph: the other graph type, faint, scaled to the plot.
|
||||
if app.ui.timeline.graph_reference && !cs.is_empty() {
|
||||
let other = curves(app, comp, ectx, tm, plot, !speed);
|
||||
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for c in &other {
|
||||
for (_, v) in &c.pts {
|
||||
rlo = rlo.min(*v);
|
||||
rhi = rhi.max(*v);
|
||||
}
|
||||
}
|
||||
if rlo.is_finite() {
|
||||
let span = (rhi - rlo).max(1e-9);
|
||||
let lines: Vec<(Color32, Vec<Pos2>)> = other
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.color,
|
||||
c.pts
|
||||
.iter()
|
||||
.map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
super::graph_tools::reference_graph(&pc, plot, &lines);
|
||||
}
|
||||
}
|
||||
// Curves.
|
||||
for c in &cs {
|
||||
let pts: Vec<Pos2> = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect();
|
||||
pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color)));
|
||||
}
|
||||
// Transform box around several selected keys (inside below the keys, handles above).
|
||||
let sel_keys: Vec<super::graph_tools::SelKey> = cs
|
||||
.iter()
|
||||
.flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable }))
|
||||
.collect();
|
||||
let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect();
|
||||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
|
||||
// Keys and handles.
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
let fr = comp.frame_rate;
|
||||
|
|
@ -298,7 +332,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let merge = format!("gkey-{uid}-{}", k.idx);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))));
|
||||
// Graph Editor ▸ Snap: to the current time and other keys.
|
||||
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
|
||||
let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct)));
|
||||
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge});
|
||||
if !speed && c.editable {
|
||||
let mut comps = c.prop.keys[k.idx].value.components();
|
||||
|
|
@ -316,6 +353,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
}
|
||||
}
|
||||
|
||||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
|
||||
|
||||
// Background: click deselects keys, drag box-selects.
|
||||
if bg.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
|
|
@ -374,6 +413,17 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
fit_selection(app, &cs, tm, plot);
|
||||
}
|
||||
x += 6.0;
|
||||
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
|
||||
if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
|
||||
app.ui.timeline.graph_snap = !snap;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") {
|
||||
app.ui.timeline.graph_reference = !reference;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
|
||||
app.ui.timeline.graph_transform_box = !tbox;
|
||||
}
|
||||
x += 6.0;
|
||||
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| {
|
||||
let layer = comp.layer(*l)?;
|
||||
let tr = layer.transform()?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
//! Graph Editor and timeline keyframe tools: the transform box around several selected keys
|
||||
//! (scale and move them in time and value), key-time snapping, and Alt-drag of a key group's
|
||||
//! first or last key in the timeline to scale the group in time. Edits are `keys.transform`.
|
||||
|
||||
use effectcraft_engine::project::Comp;
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
type Actions = Vec<(String, Value)>;
|
||||
|
||||
/// A selected key point in the graph: comp seconds, graph value, the dimension it shows, and
|
||||
/// whether its value is editable there.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SelKey {
|
||||
pub t: f64,
|
||||
pub v: f64,
|
||||
pub dim: usize,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// Transform box geometry (data space): [t0, v0, t1, v1].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct BoxDrag {
|
||||
/// 0–3 corners (t0v1, t1v1, t1v0, t0v0 = screen TL, TR, BR, BL), 4–7 edges (top, right,
|
||||
/// bottom, left), 8 = move.
|
||||
handle: usize,
|
||||
/// Fixed opposite point (comp s, value).
|
||||
anchor: [f64; 2],
|
||||
/// Pointer data position of the previous frame.
|
||||
last: [f64; 2],
|
||||
}
|
||||
|
||||
fn box_id() -> egui::Id {
|
||||
egui::Id::new("graph-transform-box")
|
||||
}
|
||||
|
||||
/// Bounds of the selected keys: (t0, v0, t1, v1).
|
||||
fn bounds(keys: &[SelKey]) -> Option<[f64; 4]> {
|
||||
if keys.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for k in keys {
|
||||
b = [b[0].min(k.t), b[1].min(k.v), b[2].max(k.t), b[3].max(k.v)];
|
||||
}
|
||||
(b[2] > b[0] || b[3] > b[1]).then_some(b)
|
||||
}
|
||||
|
||||
fn handle_points(r: Rect) -> [Pos2; 8] {
|
||||
let c = r.center();
|
||||
[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), pos2(c.x, r.min.y), pos2(r.max.x, c.y), pos2(c.x, r.max.y), pos2(r.min.x, c.y)]
|
||||
}
|
||||
|
||||
/// The transform box for two or more selected keys. Call before the keys are drawn (its inside
|
||||
/// sits below them) with `handles = false`, and after them with `handles = true` (the handles sit
|
||||
/// on top). Dragging a corner scales time and value about the opposite corner, an edge one axis,
|
||||
/// the inside moves the keys; times land on frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn transform_box(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
keys: &[SelKey],
|
||||
tm: TMap,
|
||||
ymap: &dyn Fn(f64) -> f32,
|
||||
vmap: &dyn Fn(f32) -> f64,
|
||||
speed: bool,
|
||||
handles: bool,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
if !app.ui.timeline.graph_transform_box {
|
||||
return;
|
||||
}
|
||||
let Some(b) = bounds(keys) else { return };
|
||||
let r = Rect::from_min_max(pos2(tm.x(b[0]), ymap(b[3])), pos2(tm.x(b[2]), ymap(b[1]))).expand(6.0);
|
||||
let value_ok = !speed && keys.iter().all(|k| k.editable);
|
||||
let col = Color32::from_rgb(0xd8, 0xd8, 0xd8);
|
||||
let ctx = ui.ctx().clone();
|
||||
let drag_id = egui::Id::new("graph-dragging");
|
||||
let hp = handle_points(r);
|
||||
let data = |q: Pos2| [tm.t(q.x), vmap(q.y)];
|
||||
// Which data point each handle sits on, and its opposite.
|
||||
let corner = |i: usize| -> [f64; 2] {
|
||||
match i {
|
||||
0 => [b[0], b[3]],
|
||||
1 => [b[2], b[3]],
|
||||
2 => [b[2], b[1]],
|
||||
3 => [b[0], b[1]],
|
||||
4 => [(b[0] + b[2]) / 2.0, b[3]],
|
||||
5 => [b[2], (b[1] + b[3]) / 2.0],
|
||||
6 => [(b[0] + b[2]) / 2.0, b[1]],
|
||||
_ => [b[0], (b[1] + b[3]) / 2.0],
|
||||
}
|
||||
};
|
||||
let mut start: Option<BoxDrag> = None;
|
||||
let mut resp_any: Option<egui::Response> = None;
|
||||
if !handles {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle);
|
||||
let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag());
|
||||
app.auto.add("timeline.graph.transformBox", r, "Transform box");
|
||||
if resp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
start = Some(BoxDrag { handle: 8, anchor: [0.0; 2], last: data(o) });
|
||||
}
|
||||
resp_any = Some(resp);
|
||||
} else {
|
||||
for (i, h) in hp.iter().enumerate() {
|
||||
// Value handles only when values can change.
|
||||
if !value_ok && matches!(i, 4 | 6) {
|
||||
continue;
|
||||
}
|
||||
let hr = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
p.rect_filled(hr, 0.0, Color32::from_gray(0x20));
|
||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle);
|
||||
let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle");
|
||||
if resp.drag_started() {
|
||||
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
|
||||
start = Some(BoxDrag { handle: i, anchor: corner(opp), last: corner(i) });
|
||||
}
|
||||
if resp.dragged() || resp.drag_stopped() {
|
||||
resp_any = Some(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(box_id(), s);
|
||||
d.insert_temp(drag_id, true);
|
||||
});
|
||||
}
|
||||
let Some(resp) = resp_any else { return };
|
||||
let Some(mut st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
if (st.handle == 8) == handles {
|
||||
return;
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cur = data(pt);
|
||||
let mut params = json!({"merge": "graph-box"});
|
||||
if st.handle == 8 {
|
||||
params["timeOffset"] = json!(cur[0] - st.last[0]);
|
||||
if value_ok {
|
||||
params["valueOffset"] = json!(cur[1] - st.last[1]);
|
||||
}
|
||||
st.last = cur;
|
||||
} else {
|
||||
let (time_axis, value_axis) = match st.handle {
|
||||
4 | 6 => (false, true),
|
||||
5 | 7 => (true, false),
|
||||
_ => (true, true),
|
||||
};
|
||||
// Scale relative to where the handle is now (incremental, so merged steps compose).
|
||||
let now = corner(st.handle);
|
||||
if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 {
|
||||
let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]);
|
||||
if k > 0.01 {
|
||||
params["timeScale"] = json!(k);
|
||||
params["timeAnchor"] = json!(st.anchor[0]);
|
||||
}
|
||||
}
|
||||
if value_axis && value_ok && (now[1] - st.anchor[1]).abs() > 1e-9 {
|
||||
params["valueScale"] = json!((cur[1] - st.anchor[1]) / (now[1] - st.anchor[1]));
|
||||
params["valueAnchor"] = json!(st.anchor[1]);
|
||||
}
|
||||
}
|
||||
let mut dims: Vec<usize> = keys.iter().map(|k| k.dim).collect();
|
||||
dims.sort_unstable();
|
||||
dims.dedup();
|
||||
params["dims"] = json!(dims);
|
||||
if params.as_object().is_some_and(|m| m.len() > 2) {
|
||||
actions.push(("keys.transform".into(), params));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(box_id(), st));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<BoxDrag>(box_id());
|
||||
d.insert_temp(drag_id, false);
|
||||
});
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's
|
||||
/// time within 6 points.
|
||||
pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 {
|
||||
if !app.ui.timeline.graph_snap {
|
||||
return t;
|
||||
}
|
||||
let cti = app.session.time().seconds();
|
||||
let x = tm.x(t);
|
||||
std::iter::once(cti)
|
||||
.chain(others.iter().copied())
|
||||
.map(|c| (c, (tm.x(c) - x).abs()))
|
||||
.filter(|(_, d)| *d < 6.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(c, _)| c)
|
||||
.unwrap_or(t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline Alt-drag scaling
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AltScale {
|
||||
/// The fixed end of the group (comp s).
|
||||
anchor: f64,
|
||||
/// The dragged end is the group's last key (else its first).
|
||||
end_is_max: bool,
|
||||
}
|
||||
|
||||
fn alt_id() -> egui::Id {
|
||||
egui::Id::new("timeline-alt-scale")
|
||||
}
|
||||
|
||||
/// Selected keys' comp-time range.
|
||||
fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> {
|
||||
let ts: Vec<f64> = app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
|
||||
if ts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let lo = ts.iter().copied().fold(f64::MAX, f64::min);
|
||||
let hi = ts.iter().copied().fold(f64::MIN, f64::max);
|
||||
(hi - lo > 1e-9).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// Alt-drag on the first or last key of a selected group starts scaling the group in time
|
||||
/// about the other end. Returns true when it took the drag.
|
||||
pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool {
|
||||
let Some((lo, hi)) = sel_range(app, comp) else { return false };
|
||||
let st = if (key_ct - hi).abs() < 1e-6 {
|
||||
AltScale { anchor: lo, end_is_max: true }
|
||||
} else if (key_ct - lo).abs() < 1e-6 {
|
||||
AltScale { anchor: hi, end_is_max: false }
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), st));
|
||||
true
|
||||
}
|
||||
|
||||
/// While an Alt-drag scale is active: scale the group so its dragged end follows the pointer.
|
||||
pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, tm: TMap, actions: &mut Actions) {
|
||||
let Some(st) = ctx.data(|d| d.get_temp::<AltScale>(alt_id())) else { return };
|
||||
if !ctx.input(|i| i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| d.remove::<AltScale>(alt_id()));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
return;
|
||||
}
|
||||
let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return };
|
||||
let fr = comp.frame_rate;
|
||||
let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds();
|
||||
let end = if st.end_is_max { hi } else { lo };
|
||||
let span = end - st.anchor;
|
||||
let want = target - st.anchor;
|
||||
if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() {
|
||||
return;
|
||||
}
|
||||
let k = want / span;
|
||||
if (k - 1.0).abs() > 1e-6 {
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"})));
|
||||
}
|
||||
}
|
||||
|
||||
/// An Alt-drag group scale is in progress (the plain key move stands aside).
|
||||
pub(crate) fn alt_scale_active(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<AltScale>(alt_id())).is_some()
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Show Reference Graph: the other graph type, normalized to the plot, faint.
|
||||
pub(crate) fn reference_graph(p: &egui::Painter, plot: Rect, curves: &[(Color32, Vec<Pos2>)]) {
|
||||
for (c, pts) in curves {
|
||||
p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35))));
|
||||
}
|
||||
let _ = plot;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -23,6 +24,9 @@ pub mod text_panels;
|
|||
pub mod timeline;
|
||||
pub mod tracker;
|
||||
pub mod viewer;
|
||||
pub mod viewer_overlays;
|
||||
pub mod viewer_text;
|
||||
pub mod viewer_tools;
|
||||
pub mod waveform;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
|
|
|||
|
|
@ -107,8 +107,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------- Text / Paragraph / Animation
|
||||
// While the layer's text is edited in the viewer, the text sections show and change the
|
||||
// selected characters.
|
||||
let text_target = crate::panels::text_panels::text_target(app).filter(|tt| tt.layer == layer.id.0);
|
||||
if matches!(layer.source, LayerSource::Text)
|
||||
&& let Some(doc) = effectcraft_engine::render::text::source_text(&ectx, &layer)
|
||||
&& let Some(doc) = text_target.as_ref().map(|tt| tt.doc.clone()).or_else(|| effectcraft_engine::render::text::source_text(&ectx, &layer))
|
||||
{
|
||||
divider(&p, x0, w, y - 4.0, &t);
|
||||
y = text_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
|
||||
|
|
@ -166,6 +169,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6));
|
||||
}
|
||||
for (id, params) in actions {
|
||||
let params = match &text_target {
|
||||
Some(tt) if id == "layer.setText" => tt.params(params),
|
||||
_ => params,
|
||||
};
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
//! Character, Paragraph and Align panels.
|
||||
//! Character, Paragraph and Align panels. While a text layer is edited in the viewer the
|
||||
//! Character and Paragraph panels show and change the selected text (creating style runs);
|
||||
//! otherwise they apply to the whole selected text layer.
|
||||
|
||||
use effectcraft_engine::keyframe::{Justify, TextDoc};
|
||||
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, Justify, Kerning, TextDoc};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
|
@ -9,10 +11,44 @@ use crate::icons::{self, Icon};
|
|||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
/// The selected text layer's Source Text at the CTI.
|
||||
fn text_doc(app: &EffectcraftApp) -> Option<(u64, TextDoc)> {
|
||||
/// What the text panels act on: a text layer, a document whose base style and first paragraph
|
||||
/// show the selection's formatting, and the selected character range while editing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextTarget {
|
||||
pub layer: u64,
|
||||
pub doc: TextDoc,
|
||||
pub range: Option<[usize; 2]>,
|
||||
}
|
||||
|
||||
impl TextTarget {
|
||||
/// `layer.setText` params for this target.
|
||||
pub fn params(&self, mut v: serde_json::Value) -> serde_json::Value {
|
||||
v["layer"] = json!(self.layer);
|
||||
if let Some(r) = self.range {
|
||||
v["range"] = json!(r);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// The edited text layer's selection, else the selected text layer's Source Text at the CTI.
|
||||
pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
|
||||
let comp = app.session.active_comp()?;
|
||||
let cid = app.session.active_comp_id()?;
|
||||
if let Some(e) = app.session.state.text_edit.clone()
|
||||
&& let Some(full) = effectcraft_engine::commands::text_edit::layer_doc(&app.session, e.layer)
|
||||
{
|
||||
let r = e.range();
|
||||
let style = e.pending.clone().unwrap_or_else(|| if r.is_empty() { full.insertion_style(r.start) } else { full.style_at(r.start) });
|
||||
let para = full.para(full.para_of(r.start));
|
||||
let mut view = full.clone();
|
||||
view.runs.clear();
|
||||
view.paragraphs.clear();
|
||||
view.apply_style_all(|s| *s = style.clone());
|
||||
let n = view.para_count();
|
||||
view.set_paras(vec![para; n]);
|
||||
return Some(TextTarget { layer: e.layer.0, doc: view, range: Some([r.start, r.end]) });
|
||||
}
|
||||
let layer = app
|
||||
.session
|
||||
.state
|
||||
|
|
@ -21,15 +57,36 @@ fn text_doc(app: &EffectcraftApp) -> Option<(u64, TextDoc)> {
|
|||
.filter_map(|id| comp.layer(*id))
|
||||
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref() };
|
||||
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| (layer.id.0, d))
|
||||
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
|
||||
}
|
||||
|
||||
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::new(("text-toggle", id)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
let font = if bold { Tokens::semibold(13.0) } else { Tokens::ui(12.0) };
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, font, if on { egui::Color32::WHITE } else { t.text });
|
||||
app.auto.add(id, r, label);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let (lid, doc) = text_doc(app).unwrap_or((0, TextDoc::default()));
|
||||
let enabled = lid != 0;
|
||||
let target = text_target(app);
|
||||
let enabled = target.is_some();
|
||||
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let x0 = rect.min.x + 10.0;
|
||||
let w = rect.width() - 20.0;
|
||||
|
|
@ -57,16 +114,18 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(json!({"style": styles[i]}));
|
||||
}
|
||||
y += 34.0;
|
||||
// Numeric fields in AE's two-column grid.
|
||||
let fields: [(&str, &str, f64, (f64, f64), &str); 8] = [
|
||||
// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).
|
||||
let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
|
||||
("size", "T", doc.size, (1.0, 2000.0), " px"),
|
||||
("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
|
||||
("", "", 0.0, (0.0, 0.0), ""),
|
||||
("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
|
||||
("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
|
||||
("", "", 0.0, (0.0, 0.0), ""),
|
||||
("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
|
||||
("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
|
||||
("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
|
||||
("", "", 0.0, (0.0, 0.0), ""),
|
||||
("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
|
||||
];
|
||||
for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
|
||||
if key.is_empty() {
|
||||
|
|
@ -85,10 +144,35 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(json!({key: nv, "merge": format!("char-{key}")}));
|
||||
}
|
||||
}
|
||||
// Stroke Over Fill / Fill Over Stroke (bottom-right cell).
|
||||
// Kerning: Metrics / Optical / a manual value (row 1, left).
|
||||
{
|
||||
let fy = y + 28.0;
|
||||
p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
|
||||
let label = match doc.kerning {
|
||||
Kerning::Metrics => "Metrics".to_string(),
|
||||
Kerning::Optical => "Optical".to_string(),
|
||||
Kerning::Manual(v) => format!("{v:.0}"),
|
||||
};
|
||||
let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
|
||||
let kpop = egui::Id::new("char-kerning-pop");
|
||||
if widgets::dropdown(ui, kr, &label, &t, egui::Id::new("char-kerning")).clicked() && enabled {
|
||||
widgets::open_popup(ui, kpop);
|
||||
}
|
||||
app.auto.add("character.kerning", kr, "Kerning");
|
||||
let opts: Vec<String> = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
|
||||
let v = match i {
|
||||
0 => json!("metrics"),
|
||||
1 => json!("optical"),
|
||||
_ => json!(opts[i].parse::<f64>().unwrap_or(0.0)),
|
||||
};
|
||||
actions.push(json!({"kerning": v}));
|
||||
}
|
||||
}
|
||||
// Stroke Over Fill / Fill Over Stroke (row 2, right).
|
||||
{
|
||||
let fx = x0 + w / 2.0;
|
||||
let fy = y + 3.0 * 28.0;
|
||||
let fy = y + 2.0 * 28.0;
|
||||
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
|
||||
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
|
||||
let pop = egui::Id::new("char-stroke-order-pop");
|
||||
|
|
@ -100,80 +184,126 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(json!({"strokeOverFill": i == 1}));
|
||||
}
|
||||
}
|
||||
y += 4.0 * 28.0 + 8.0;
|
||||
// Fill / stroke swatches.
|
||||
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "Fill", Tokens::ui(11.5), t.text_dim);
|
||||
let fs = Rect::from_min_size(pos2(x0 + 34.0, y), vec2(26.0, 20.0));
|
||||
if widgets::swatch(ui, fs, doc.fill, egui::Id::new("char-fill"), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, egui::Id::new("char-fill-pop"));
|
||||
}
|
||||
app.auto.add("character.fill", fs, "Fill color");
|
||||
let mut fc = [doc.fill[0], doc.fill[1], doc.fill[2]];
|
||||
if crate::header::color_popup(ui, egui::Id::new("char-fill-pop"), fs.left_bottom(), &mut fc) {
|
||||
actions.push(json!({"fill": [fc[0], fc[1], fc[2]], "merge": "char-fill"}));
|
||||
}
|
||||
p.text(pos2(x0 + 80.0, y + 10.0), Align2::LEFT_CENTER, "Stroke", Tokens::ui(11.5), t.text_dim);
|
||||
let ss = Rect::from_min_size(pos2(x0 + 126.0, y), vec2(26.0, 20.0));
|
||||
if widgets::swatch(ui, ss, doc.stroke, egui::Id::new("char-stroke"), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, egui::Id::new("char-stroke-pop"));
|
||||
}
|
||||
app.auto.add("character.stroke", ss, "Stroke color");
|
||||
let mut sc = [doc.stroke[0], doc.stroke[1], doc.stroke[2]];
|
||||
if crate::header::color_popup(ui, egui::Id::new("char-stroke-pop"), ss.left_bottom(), &mut sc) {
|
||||
actions.push(json!({"stroke": [sc[0], sc[1], sc[2]], "merge": "char-stroke"}));
|
||||
y += 5.0 * 28.0 + 8.0;
|
||||
// Fill / stroke swatches with their enable boxes.
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let fx = x0 + i as f32 * (w / 2.0);
|
||||
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled {
|
||||
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
|
||||
}
|
||||
app.auto.add(&format!("character.{key}.enabled"), cr, label);
|
||||
let fs = Rect::from_min_size(pos2(fx + 20.0, y), vec2(26.0, 20.0));
|
||||
let pop = egui::Id::new(("char-color-pop", key));
|
||||
if widgets::swatch(ui, fs, c, egui::Id::new(("char-color", key)), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("character.{key}"), fs, label);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) {
|
||||
actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")}));
|
||||
}
|
||||
p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
y += 32.0;
|
||||
// Style toggles.
|
||||
let toggles =
|
||||
[("fauxBold", "T", doc.faux_bold), ("fauxItalic", "T", doc.faux_italic), ("allCaps", "TT", doc.all_caps), ("smallCaps", "Tт", doc.small_caps)];
|
||||
for (i, (key, label, on)) in toggles.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0, y), vec2(30.0, 24.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("char-t", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
let font = if key == "fauxBold" { Tokens::semibold(13.0) } else { Tokens::ui(12.0) };
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, font, if on { egui::Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("character.{key}"), r, key);
|
||||
if resp.clicked() && enabled {
|
||||
// Style toggles: Faux Bold, Faux Italic, All Caps, Small Caps, Superscript, Subscript.
|
||||
let sup = doc.baseline == BaselineOption::Superscript;
|
||||
let sub = doc.baseline == BaselineOption::Subscript;
|
||||
let toggles = [
|
||||
("fauxBold", "T", doc.faux_bold, true),
|
||||
("fauxItalic", "T", doc.faux_italic, false),
|
||||
("allCaps", "TT", doc.all_caps, false),
|
||||
("smallCaps", "Tт", doc.small_caps, false),
|
||||
("superscript", "T¹", sup, false),
|
||||
("subscript", "T₁", sub, false),
|
||||
];
|
||||
let bw = ((w - 5.0 * 4.0) / 6.0).clamp(22.0, 30.0);
|
||||
for (i, (key, label, on, bold)) in toggles.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y), vec2(bw, 24.0));
|
||||
if toggle(app, ui, &p, r, &format!("character.{key}"), label, on, bold) && enabled {
|
||||
actions.push(json!({key: !on}));
|
||||
}
|
||||
}
|
||||
if !enabled {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
|
||||
y += 32.0;
|
||||
// Ligatures.
|
||||
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled {
|
||||
actions.push(json!({"ligatures": !doc.ligatures}));
|
||||
}
|
||||
for mut a in actions {
|
||||
a["layer"] = json!(lid);
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", a) {
|
||||
app.auto.add("character.ligatures", lr, "Ligatures");
|
||||
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim);
|
||||
match &target {
|
||||
None => {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
Some(tt) if tt.range.is_some() => {
|
||||
let [a, b] = tt.range.unwrap_or_default();
|
||||
let msg = if a == b { "Editing: caret (applies to typed text)".to_string() } else { format!("Editing: {} characters selected", b - a) };
|
||||
p.text(pos2(x0, rect.max.y - 14.0), Align2::LEFT_CENTER, msg, Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(tt) = target else { return };
|
||||
for a in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", tt.params(a)) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The seven Paragraph panel alignment buttons: (justify, `layer.setText` key).
|
||||
const ALIGNS: [(Justify, &str); 7] = [
|
||||
(Justify::Left, "left"),
|
||||
(Justify::Center, "center"),
|
||||
(Justify::Right, "right"),
|
||||
(Justify::JustifyLastLeft, "justifyLeft"),
|
||||
(Justify::JustifyLastCenter, "justifyCenter"),
|
||||
(Justify::JustifyLastRight, "justifyRight"),
|
||||
(Justify::JustifyAll, "justifyAll"),
|
||||
];
|
||||
|
||||
/// Lines glyph of an alignment button.
|
||||
pub fn paint_justify_glyph(p: &egui::Painter, r: Rect, j: Justify, c: egui::Color32) {
|
||||
for k in 0..4 {
|
||||
let ly = r.min.y + 6.0 + k as f32 * 4.0;
|
||||
let full = r.width() - 10.0;
|
||||
let last = k == 3;
|
||||
let justified = !matches!(j, Justify::Left | Justify::Center | Justify::Right);
|
||||
let lw = if (justified && !last) || j == Justify::JustifyAll {
|
||||
full
|
||||
} else if k % 2 == 1 || last {
|
||||
full * 0.6
|
||||
} else {
|
||||
full
|
||||
};
|
||||
let anchor = match j {
|
||||
Justify::Center | Justify::JustifyLastCenter => 1,
|
||||
Justify::Right | Justify::JustifyLastRight => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let lx = match anchor {
|
||||
1 => r.center().x - lw / 2.0,
|
||||
2 => r.max.x - 5.0 - lw,
|
||||
_ => r.min.x + 5.0,
|
||||
};
|
||||
p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], egui::Stroke::new(1.4, c));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let (lid, doc) = text_doc(app).unwrap_or((0, TextDoc::default()));
|
||||
let target = text_target(app);
|
||||
let enabled = target.is_some();
|
||||
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
|
||||
let x0 = rect.min.x + 10.0;
|
||||
let y = rect.min.y + 10.0;
|
||||
let opts = [
|
||||
(Justify::Left, "left"),
|
||||
(Justify::Center, "center"),
|
||||
(Justify::Right, "right"),
|
||||
(Justify::JustifyLastLeft, "justify"),
|
||||
(Justify::JustifyAll, "justifyAll"),
|
||||
];
|
||||
for (i, (j, key)) in opts.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 32.0, y), vec2(28.0, 26.0));
|
||||
let w = rect.width() - 20.0;
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let mut actions: Vec<serde_json::Value> = vec![];
|
||||
let bw = ((w - 6.0 * 3.0) / 7.0).clamp(20.0, 28.0);
|
||||
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 3.0), y), vec2(bw, 24.0));
|
||||
let on = doc.justify == j;
|
||||
let resp = ui.interact(r, egui::Id::new(("para", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
|
|
@ -187,26 +317,77 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
t.field_bg
|
||||
},
|
||||
);
|
||||
let c = if on { egui::Color32::WHITE } else { t.text };
|
||||
for k in 0..4 {
|
||||
let ly = r.min.y + 7.0 + k as f32 * 4.0;
|
||||
let full = r.width() - 12.0;
|
||||
let lw = if k % 2 == 1 && !matches!(j, Justify::JustifyAll) { full * 0.65 } else { full };
|
||||
let lx = match j {
|
||||
Justify::Center => r.center().x - lw / 2.0,
|
||||
Justify::Right => r.max.x - 6.0 - lw,
|
||||
_ => r.min.x + 6.0,
|
||||
};
|
||||
p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], egui::Stroke::new(1.4, c));
|
||||
}
|
||||
paint_justify_glyph(&p, r, j, if on { egui::Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("paragraph.{key}"), r, key);
|
||||
if resp.clicked() && lid != 0 {
|
||||
let _ = crate::menus::invoke(app, &ctx, "layer.setText", json!({"layer": lid, "justify": key}));
|
||||
if resp.clicked() && enabled {
|
||||
actions.push(json!({"justify": key}));
|
||||
}
|
||||
}
|
||||
if lid == 0 {
|
||||
y += 34.0;
|
||||
// Indents and spacing (AE's two-column grid).
|
||||
let fields: [(&str, &str, f64); 6] = [
|
||||
("indentLeft", "→|", doc.indent_left),
|
||||
("indentRight", "|←", doc.indent_right),
|
||||
("indentFirst", "→¶", doc.indent_first),
|
||||
("", "", 0.0),
|
||||
("spaceBefore", "↑¶", doc.space_before),
|
||||
("spaceAfter", "¶↓", doc.space_after),
|
||||
];
|
||||
for (i, (key, glyph, v)) in fields.into_iter().enumerate() {
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fx = x0 + (i % 2) as f32 * (w / 2.0);
|
||||
let fy = y + (i / 2) as f32 * 28.0;
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::new(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
|
||||
app.auto.add(&format!("paragraph.{key}"), r, key);
|
||||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
{
|
||||
actions.push(json!({key: nv, "merge": format!("para-{key}")}));
|
||||
}
|
||||
}
|
||||
y += 3.0 * 28.0 + 6.0;
|
||||
// Direction and composer popups.
|
||||
let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0));
|
||||
let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()];
|
||||
let dpop = egui::Id::new("para-direction-pop");
|
||||
let rtl = doc.direction == Direction::Rtl;
|
||||
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled {
|
||||
widgets::open_popup(ui, dpop);
|
||||
}
|
||||
app.auto.add("paragraph.direction", dr, "Text direction");
|
||||
if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) {
|
||||
actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }}));
|
||||
}
|
||||
let cr = Rect::from_min_size(pos2(x0 + w / 2.0, y), vec2(w / 2.0, 22.0));
|
||||
let comps = vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()];
|
||||
let cpop = egui::Id::new("para-composer-pop");
|
||||
let single = doc.composer == Composer::SingleLine;
|
||||
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled {
|
||||
widgets::open_popup(ui, cpop);
|
||||
}
|
||||
app.auto.add("paragraph.composer", cr, "Composer");
|
||||
if let Some(i) = widgets::popup_menu(ui, cpop, cr.left_bottom(), &comps, Some(single as usize)) {
|
||||
actions.push(json!({"composer": if i == 1 { "singleLine" } else { "everyLine" }}));
|
||||
}
|
||||
y += 30.0;
|
||||
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled {
|
||||
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
|
||||
}
|
||||
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
|
||||
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Roman Hanging Punctuation", Tokens::ui(11.5), t.text_dim);
|
||||
if !enabled {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
let Some(tt) = target else { return };
|
||||
for a in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", tt.params(a)) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Align panel: align selected layers to the composition (or to the selection).
|
||||
|
|
|
|||
|
|
@ -1352,7 +1352,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if kresp.dragged() {
|
||||
// Alt-drag the first or last key of a selected group: scale the group in time.
|
||||
if kresp.drag_started()
|
||||
&& ui.input(|i| i.modifiers.alt)
|
||||
&& let Some(c) = app.session.active_comp().cloned()
|
||||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
let alt_scaling = super::graph_tools::alt_scale_active(&ctx);
|
||||
if kresp.dragged() && !alt_scaling {
|
||||
let acc_id = egui::Id::new("key-drag-acc");
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += kresp.drag_delta().x;
|
||||
|
|
@ -1396,6 +1404,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);
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@
|
|||
//! camera/light wireframes.
|
||||
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
|
||||
use effectcraft_engine::project::{Layer, LayerId};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::icons::Icon;
|
||||
use crate::state::{Resolution, Tool};
|
||||
use crate::state::Tool;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
|
|
@ -62,6 +61,8 @@ enum Gesture {
|
|||
Move {
|
||||
layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>,
|
||||
start: [f64; 2],
|
||||
/// The grabbed layer's feature nearest the pointer (comp px at the start): it snaps.
|
||||
snap_src: Option<[f64; 2]>,
|
||||
},
|
||||
Camera {
|
||||
tool: Tool,
|
||||
|
|
@ -96,10 +97,14 @@ enum Gesture {
|
|||
Marquee {
|
||||
start: Pos2,
|
||||
},
|
||||
/// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer).
|
||||
/// Dragging selected mask / shape path vertices: press point, the grabbed vertex (comp px,
|
||||
/// it snaps), the comp-space move applied so far and comp → path space.
|
||||
Vertices {
|
||||
last: [f64; 2],
|
||||
start: [f64; 2],
|
||||
src: [f64; 2],
|
||||
applied: [f64; 2],
|
||||
inv: Mat3,
|
||||
layer: LayerId,
|
||||
},
|
||||
/// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them).
|
||||
Tangent {
|
||||
|
|
@ -131,6 +136,9 @@ enum Gesture {
|
|||
tracker: u64,
|
||||
drag: super::tracker::Drag,
|
||||
},
|
||||
/// Motion paths, free transform, Mask Feather, guides and the region of interest
|
||||
/// (`viewer_overlays`).
|
||||
Overlay(ov::Drag),
|
||||
}
|
||||
|
||||
/// Pen tool path in progress: (layer, mask uid).
|
||||
|
|
@ -138,19 +146,8 @@ fn pen_id() -> egui::Id {
|
|||
egui::Id::new("viewer-pen")
|
||||
}
|
||||
|
||||
/// Mask vertex / tangent hit targets drawn this frame.
|
||||
#[derive(Clone, Copy)]
|
||||
struct VertexHit {
|
||||
layer: LayerId,
|
||||
mask: u64,
|
||||
index: usize,
|
||||
pos: Pos2,
|
||||
/// None = the vertex itself, Some(out?) = a tangent handle.
|
||||
tangent: Option<bool>,
|
||||
/// Layer-space vertex position and layer → comp matrix.
|
||||
vertex: [f64; 2],
|
||||
l2c: Mat3,
|
||||
}
|
||||
use super::viewer_overlays::{self as ov, VertexHit};
|
||||
use super::viewer_tools as vt;
|
||||
|
||||
const HANDLE: f32 = 7.0;
|
||||
|
||||
|
|
@ -356,6 +353,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// keeps the overlays and the interaction.
|
||||
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
|
||||
p.rect_filled(area, 0.0, pasteboard);
|
||||
// View ▸ Show Rulers takes a strip on the top and left.
|
||||
let outer = area;
|
||||
let area = vt::inset(app, area);
|
||||
|
||||
let (cw, ch) = (comp.width as f32, comp.height as f32);
|
||||
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
|
||||
|
|
@ -392,11 +392,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_shown
|
||||
&& k.comp == cid.0
|
||||
{
|
||||
painter.image(*tex, 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");
|
||||
|
|
@ -445,6 +445,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]);
|
||||
|
|
@ -462,110 +464,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
|
||||
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
|
||||
painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
}
|
||||
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
|
||||
}
|
||||
|
||||
// Overlays + interaction.
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
|
||||
let mut vertex_hits: Vec<VertexHit> = Vec::new();
|
||||
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
|
||||
let mut paths: Vec<ov::PathInfo> = Vec::new();
|
||||
let sel_vertices = app.session.state.selected_vertices.clone();
|
||||
// Pen path in progress ends when the tool changes or its mask is gone.
|
||||
if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) {
|
||||
// Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
|
||||
// free transform.
|
||||
let finish = ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input();
|
||||
if app.ui.tool != Tool::Pen || finish {
|
||||
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
|
||||
}
|
||||
if finish || app.ui.tool != Tool::Selection {
|
||||
ov::end_free_transform(&ctx);
|
||||
}
|
||||
if app.ui.viewer.show_layer_controls {
|
||||
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
|
||||
// Layer Handles and Paths.
|
||||
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
|
||||
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
|
||||
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
|
||||
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
|
||||
let col = if label_handles { t.label(l.label) } else { t.accent };
|
||||
// Motion path of animated position.
|
||||
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
|
||||
&& pos.keys.len() > 1
|
||||
&& l.parent.is_none()
|
||||
{
|
||||
let lt0 = pos.keys.first().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
let lt1 = pos.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
let fd = comp.frame_duration();
|
||||
let mut pts = vec![];
|
||||
let mut lt = lt0;
|
||||
while lt <= lt1 {
|
||||
let v = pos.value_at(lt).as_vec2();
|
||||
pts.push(map.to_screen(v));
|
||||
lt += fd;
|
||||
}
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, col.gamma_multiply(0.8))));
|
||||
for q in pts.iter().step_by(1) {
|
||||
painter.circle_filled(*q, 1.2, col.gamma_multiply(0.8));
|
||||
}
|
||||
for k in &pos.keys {
|
||||
let v = k.value.as_vec2();
|
||||
let s = map.to_screen(v);
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, col);
|
||||
}
|
||||
}
|
||||
// Masks.
|
||||
if app.ui.viewer.show_masks
|
||||
&& let Some(masks) = l.masks()
|
||||
{
|
||||
let (m, _) = l2c(&ectx, l);
|
||||
for g in masks.groups() {
|
||||
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
|
||||
if locked && app.session.state.hide_locked_masks {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue };
|
||||
let Some(sp) = path.as_path() else { continue };
|
||||
let k = effectcraft_engine::render::kurbo_path(sp);
|
||||
let mut pts: Vec<Pos2> = vec![];
|
||||
kurbo::flatten(k.iter(), 0.5, |el| match el {
|
||||
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
|
||||
let c = m.apply(gv2(q.x, q.y));
|
||||
pts.push(map.to_screen([c.x, c.y]));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if sp.closed && !pts.is_empty() {
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
let mc = Color32::from_rgb(color[0], color[1], color[2]);
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc)));
|
||||
let scr = |p: [f64; 2]| {
|
||||
let c = m.apply(gv2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
for (i, v) in sp.vertices.iter().enumerate() {
|
||||
let s = scr(*v);
|
||||
let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i);
|
||||
// Selected vertices: filled with their tangent handles; others hollow.
|
||||
if selected {
|
||||
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
|
||||
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
|
||||
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, mc));
|
||||
painter.circle_filled(h, 3.0, mc);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
|
||||
}
|
||||
// Pen rubber band from the last vertex to the pointer.
|
||||
if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid))
|
||||
&& let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos()))
|
||||
{
|
||||
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
|
||||
}
|
||||
}
|
||||
// Motion path of animated position (keyframes and spatial tangents are draggable).
|
||||
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits);
|
||||
// Masks and shape paths.
|
||||
if app.ui.viewer.show_masks {
|
||||
let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths);
|
||||
}
|
||||
let Some((m, q, _)) = layer_quad(&ectx, l) else {
|
||||
// Cameras, lights, empty layers: just the anchor.
|
||||
|
|
@ -628,6 +562,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);
|
||||
|
|
@ -670,7 +607,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
|
||||
|
|
@ -679,8 +617,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 {
|
||||
|
|
@ -704,6 +644,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
|
||||
|
|
@ -714,7 +655,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())
|
||||
|
|
@ -726,7 +667,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
|
||||
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied()
|
||||
};
|
||||
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
|
||||
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
|
||||
if resp.drag_started()
|
||||
&& !text_owns
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
|
|
@ -734,6 +678,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
|
||||
|
|
@ -787,7 +751,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() {
|
||||
|
|
@ -815,10 +786,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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -837,11 +820,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let d = resp.total_drag_delta().unwrap_or_default();
|
||||
app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y];
|
||||
}
|
||||
Gesture::Move { layers, start } => {
|
||||
Gesture::Move { layers, start, snap_src } => {
|
||||
let mut d = [cpt[0] - start[0], cpt[1] - start[1]];
|
||||
if mods.shift {
|
||||
if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 }
|
||||
}
|
||||
if let Some(src) = snap_src {
|
||||
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
|
||||
let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
|
||||
d = [d[0] + c[0], d[1] + c[1]];
|
||||
}
|
||||
for (lid, p0, ax, ay) in layers {
|
||||
let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
|
||||
|
|
@ -930,13 +918,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]];
|
||||
|
|
@ -983,6 +991,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let end = map.to_comp(pos);
|
||||
create_shape(app, tool, start, end, mods.shift);
|
||||
}
|
||||
Gesture::Overlay(ov::Drag::Roi { start }) => {
|
||||
let end = map.to_comp(pos);
|
||||
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
|
||||
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
|
||||
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
|
||||
let rect = [x0.round(), y0.round(), (x1 - x0).round(), (y1 - y0).round()];
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
|
||||
}
|
||||
app.ui.viewer.roi_draw = false;
|
||||
}
|
||||
// A guide dragged off the image (back to the rulers) is removed.
|
||||
Gesture::Overlay(ov::Drag::Guide { index, .. }) if !area.contains(pos) || !comp_rect.expand(40.0).contains(pos) => {
|
||||
let _ = app.session.execute("view.removeGuide", json!({"index": index}));
|
||||
}
|
||||
Gesture::Marquee { start } if vertex_hits.iter().any(|h| h.tangent.is_none() && Rect::from_two_pos(start, pos).contains(h.pos)) => {
|
||||
// Marquee over path vertices selects them.
|
||||
let r = Rect::from_two_pos(start, pos);
|
||||
let v: Vec<serde_json::Value> = vertex_hits
|
||||
.iter()
|
||||
.filter(|h| h.tangent.is_none() && r.contains(h.pos))
|
||||
.map(|h| json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}))
|
||||
.collect();
|
||||
let _ = app.session.execute("mask.selectVertices", json!({"vertices": v, "add": mods.shift}));
|
||||
}
|
||||
Gesture::Marquee { start } => {
|
||||
let a = map.to_comp(start);
|
||||
let b = map.to_comp(pos);
|
||||
|
|
@ -1007,6 +1039,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);
|
||||
|
|
@ -1019,13 +1052,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.ui.viewer.zoom = Some(nz);
|
||||
app.ui.viewer.pan = [pan.x, pan.y];
|
||||
}
|
||||
Tool::Type | Tool::TypeVertical => {
|
||||
let r = app.session.execute("layer.newText", json!({"text": "Text", "size": 96, "position": [cpt[0], cpt[1]], "justify": "left"}));
|
||||
if let Ok(v) = r {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string())));
|
||||
Tool::Type | Tool::TypeVertical => {}
|
||||
Tool::Pen | Tool::MaskFeather => {}
|
||||
Tool::PenAdd => {
|
||||
if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) {
|
||||
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt}));
|
||||
}
|
||||
}
|
||||
Tool::Pen => {}
|
||||
Tool::PenDelete => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]}));
|
||||
}
|
||||
}
|
||||
Tool::PenConvert => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
|
||||
}
|
||||
}
|
||||
Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
|
||||
}
|
||||
}
|
||||
Tool::Selection if ft.is_some() && ft.as_ref().and_then(|f| ov::begin_ft_drag(f, pos, &map)).is_none() => {
|
||||
// Clicking away from the free-transform box ends it.
|
||||
ov::end_free_transform(&ctx);
|
||||
}
|
||||
t if t.puppet_kind().is_some() => {
|
||||
if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
|
||||
&& let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
|
||||
|
|
@ -1052,22 +1104,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& app.ui.tool != Tool::Pen
|
||||
&& !text_owns
|
||||
&& app.ui.tool == Tool::Selection
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1)))
|
||||
{
|
||||
// Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free
|
||||
// Transform Points).
|
||||
ov::begin_free_transform(&ctx, l, m);
|
||||
} else if resp.double_clicked()
|
||||
&& !text_owns
|
||||
&& !app.ui.tool.is_pen()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
|
||||
match &l.source {
|
||||
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
|
||||
effectcraft_engine::project::LayerSource::Text => {
|
||||
let doc = effectcraft_engine::render::text::source_text(&ectx, l).map(|d| d.text).unwrap_or_default();
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (l.id.0, doc)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
|
||||
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
|
||||
{
|
||||
app.session.open_comp(*item);
|
||||
}
|
||||
}
|
||||
text_edit_overlay(app, ui, &map);
|
||||
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
|
||||
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
|
||||
|
|
@ -1078,7 +1134,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
|
||||
|
|
@ -1174,49 +1232,94 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
|
|||
}
|
||||
}
|
||||
|
||||
/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path.
|
||||
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) {
|
||||
/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it),
|
||||
/// add a vertex on a visible path's segment, or start a new path: a mask on the selected
|
||||
/// footage/solid layer, a shape path (Shape n group with Fill and Stroke) on the selected shape
|
||||
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
|
||||
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) {
|
||||
use effectcraft_engine::project::LayerSource;
|
||||
let gid = egui::Id::new("viewer-gesture");
|
||||
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
|
||||
let target = current.map(|c| c.0).or_else(|| {
|
||||
app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked))
|
||||
});
|
||||
let Some(lid) = target else {
|
||||
app.ui.status = "Select a layer to draw a mask with the Pen tool".into();
|
||||
return;
|
||||
};
|
||||
let Some(layer) = ectx.comp.layer(lid) else { return };
|
||||
let l2c = l2c(ectx, layer).0;
|
||||
let Some(inv) = l2c.inverse() else { return };
|
||||
let c = map.to_comp(pos);
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid);
|
||||
if let (true, Some((_, mask))) = (still_there, current) {
|
||||
let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count();
|
||||
let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none());
|
||||
if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) {
|
||||
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true}));
|
||||
let c0 = map.to_comp(pos);
|
||||
let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods);
|
||||
let c = [c0[0] + corr[0], c0[1] + corr[1]];
|
||||
if let Some((lid, uid)) = current
|
||||
&& let Some(p) = ov::path_of(ectx, lid, uid)
|
||||
&& let Some(inv) = p.m.inverse()
|
||||
{
|
||||
let n = p.sp.vertices.len();
|
||||
let first = p.sp.vertices.first().map(|v| {
|
||||
let q = p.m.apply(gv2(v[0], v[1]));
|
||||
map.to_screen([q.x, q.y])
|
||||
});
|
||||
if n >= 2 && first.is_some_and(|f| f.distance(pos) < 8.0) {
|
||||
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": uid, "closed": true}));
|
||||
ui.data_mut(|d| {
|
||||
d.remove::<(LayerId, u64)>(pen_id());
|
||||
d.remove::<Gesture>(gid);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) {
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) {
|
||||
let index = v.as_u64().unwrap_or(0) as usize;
|
||||
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos }));
|
||||
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index, vertex: lp, inv, press: pos }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]}))
|
||||
&& let Some(mask) = v["mask"].as_u64()
|
||||
{
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pen_id(), (lid, mask));
|
||||
d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos });
|
||||
});
|
||||
// On a visible path's segment the Pen adds a vertex there.
|
||||
if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) {
|
||||
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t}));
|
||||
return;
|
||||
}
|
||||
let target = app
|
||||
.session
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape))));
|
||||
let layer = target.and_then(|id| ectx.comp.layer(id));
|
||||
let (lid, uid, inv) = match layer {
|
||||
Some(l) if !matches!(l.source, LayerSource::Shape) => {
|
||||
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return };
|
||||
let Some(mask) = v["mask"].as_u64() else { return };
|
||||
(l.id, mask, inv)
|
||||
}
|
||||
Some(l) => {
|
||||
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let fill = app.ui.fill_color;
|
||||
let stroke = app.ui.stroke_color;
|
||||
let r = app.session.execute(
|
||||
"shape.newPath",
|
||||
json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||||
);
|
||||
let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return };
|
||||
(l.id, path, inv)
|
||||
}
|
||||
None => {
|
||||
// A new shape layer at the comp centre (unrotated: comp → layer is a translation).
|
||||
let fill = app.ui.fill_color;
|
||||
let stroke = app.ui.stroke_color;
|
||||
let r = app.session.execute(
|
||||
"shape.newPath",
|
||||
json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||||
);
|
||||
let Some(v) = r.ok() else { return };
|
||||
let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return };
|
||||
let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0));
|
||||
(LayerId(l), path, inv)
|
||||
}
|
||||
};
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pen_id(), (lid, uid));
|
||||
d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index: 0, vertex: [lp.x, lp.y], inv, press: pos });
|
||||
});
|
||||
}
|
||||
|
||||
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
|
||||
|
|
@ -1292,170 +1395,6 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
|
|||
);
|
||||
}
|
||||
|
||||
/// Inline text editing for a text layer (double-click or Type tool click).
|
||||
fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap) {
|
||||
let id = egui::Id::new("viewer-text-edit");
|
||||
let Some((lid, mut text)) = ui.data(|d| d.get_temp::<(u64, String)>(id)) else { return };
|
||||
let comp = app.session.active_comp().cloned();
|
||||
let Some(comp) = comp else { return };
|
||||
let Some(layer) = comp.layer(LayerId(lid)).cloned() else {
|
||||
ui.data_mut(|d| d.remove::<(u64, String)>(id));
|
||||
return;
|
||||
};
|
||||
let pos = layer.transform().and_then(|tr| tr.get("position")).map(|p| p.value_at(layer.layer_time(app.session.time())).as_vec2()).unwrap_or([0.0, 0.0]);
|
||||
let at = map.to_screen(pos) + vec2(-20.0, 10.0);
|
||||
let mut done = false;
|
||||
egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(at).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Edit text — Enter to commit, Esc to cancel").small());
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_width(320.0).desired_rows(2).font(Tokens::ui(16.0)));
|
||||
r.request_focus();
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift) {
|
||||
let _ = app.session.execute("layer.setText", json!({"layer": lid, "text": text.trim_end_matches('\n')}));
|
||||
done = true;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if done {
|
||||
ui.data_mut(|d| d.remove::<(u64, String)>(id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(id, (lid, text)));
|
||||
}
|
||||
}
|
||||
|
||||
fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &effectcraft_engine::project::Comp) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(bar, 0.0, t.panel_bg);
|
||||
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
|
||||
let cy = bar.center().y;
|
||||
let mut x = bar.min.x + 8.0;
|
||||
// Magnification.
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mag = if app.ui.viewer.zoom.is_none() { format!("Fit ({:.0}%)", fit * ppp * 100.0) } else { format!("{:.1}%", zoom * ppp * 100.0).replace(".0%", "%") };
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(92.0, 20.0));
|
||||
if widgets::dropdown(ui, r, &mag, &t, egui::Id::new("vw-mag")).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("vw-mag-pop"));
|
||||
}
|
||||
app.auto.add("viewer.magnification", r, "Magnification");
|
||||
let opts: Vec<String> = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-mag-pop"), r.left_top() - vec2(0.0, 380.0), &opts, None) {
|
||||
let o = &opts[i];
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
app.ui.viewer.zoom = match o.as_str() {
|
||||
"Fit" => None,
|
||||
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
|
||||
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
|
||||
};
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
// Toggles.
|
||||
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> bool {
|
||||
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(id)).on_hover_text(tip);
|
||||
auto.add(&format!("viewer.{id}"), r, tip);
|
||||
*x += 24.0;
|
||||
resp.clicked()
|
||||
};
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Grid, app.ui.viewer.grid || app.ui.viewer.safe_margins, "grid", "Choose grid and guide options") {
|
||||
if app.ui.viewer.safe_margins {
|
||||
app.ui.viewer.safe_margins = false;
|
||||
app.ui.viewer.grid = !app.ui.viewer.grid;
|
||||
} else {
|
||||
app.ui.viewer.safe_margins = true;
|
||||
}
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility") {
|
||||
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
|
||||
}
|
||||
// Timecode.
|
||||
let tc = crate::panels::timecode(&app.session, comp, time);
|
||||
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
|
||||
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
|
||||
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
|
||||
app.auto.add("viewer.timecode", tr, "Current time");
|
||||
x = tr.max.x + 6.0;
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot") {
|
||||
app.ui.status = "Snapshot taken".into();
|
||||
}
|
||||
let _ = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, false, "showSnapshot", "Show Snapshot");
|
||||
let _ = tog(ui, &mut app.auto, &mut x, Icon::Channel, app.ui.viewer.channel != "RGB", "channel", "Show Channel and Color Management Settings");
|
||||
// Resolution.
|
||||
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(78.0, 20.0));
|
||||
let rl = if app.ui.viewer.res == Resolution::Auto {
|
||||
let s = app.viewer_scale(zoom, ppp);
|
||||
format!(
|
||||
"Auto ({})",
|
||||
if s >= 1.0 {
|
||||
"Full"
|
||||
} else if s >= 0.5 {
|
||||
"Half"
|
||||
} else if s >= 0.33 {
|
||||
"Third"
|
||||
} else {
|
||||
"Quarter"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
app.ui.viewer.res.label().to_string()
|
||||
};
|
||||
if widgets::dropdown(ui, r, &rl, &t, egui::Id::new("vw-res")).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("vw-res-pop"));
|
||||
}
|
||||
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
|
||||
let ro: Vec<String> = Resolution::ALL.iter().map(|r| r.label().to_string()).collect();
|
||||
if let Some(i) =
|
||||
widgets::popup_menu(ui, egui::Id::new("vw-res-pop"), r.left_top() - vec2(0.0, 140.0), &ro, Resolution::ALL.iter().position(|x| *x == app.ui.viewer.res))
|
||||
{
|
||||
app.ui.viewer.res = Resolution::ALL[i];
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
let has_roi = app.session.state.region_of_interest.is_some();
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi, "roi", "Region of Interest") {
|
||||
// Toggle a centred region of interest (Composition ▸ Crop Comp to Region of Interest).
|
||||
let rect = if has_roi {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!([comp.width as f64 / 4.0, comp.height as f64 / 4.0, comp.width as f64 / 2.0, comp.height as f64 / 2.0])
|
||||
};
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") {
|
||||
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Sparkle, app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews") {
|
||||
app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview;
|
||||
}
|
||||
// 3D renderer + view.
|
||||
if comp.has_3d() {
|
||||
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0));
|
||||
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
|
||||
app.auto.add("viewer.renderer3d", r, "3D Renderer");
|
||||
x = r.max.x + 4.0;
|
||||
let cid = app.session.active_comp_id();
|
||||
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
|
||||
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("vw-cam-pop"));
|
||||
}
|
||||
app.auto.add("viewer.view3d", r, "3D View");
|
||||
let opts: Vec<String> = View3D::ALL.iter().map(|v| v.label().to_string()).collect();
|
||||
let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0);
|
||||
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) {
|
||||
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
|
||||
}
|
||||
}
|
||||
// Right: render time.
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
|
||||
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter();
|
||||
|
|
@ -1499,6 +1438,7 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames:
|
|||
}
|
||||
app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k));
|
||||
app.viewer_image = Some(img);
|
||||
vt::set_texture_roi(ctx, app.session.state.region_of_interest);
|
||||
}
|
||||
FrameImage::Gpu(f) => {
|
||||
let Some(rs) = &app.wgpu else { return };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,528 @@
|
|||
//! Composition viewer overlays and their drags: mask and shape paths (vertices, tangents,
|
||||
//! segments for Add Vertex), position motion paths with keyframe dots and spatial Bezier
|
||||
//! handles, the path free-transform box and the Mask Feather tool.
|
||||
//!
|
||||
//! Every edit is an engine command (`mask.*`, `path.freeTransform`, `keys.set`,
|
||||
//! `keys.setSpatialTangents`, `prop.set`).
|
||||
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::keyframe::ShapePath;
|
||||
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_engine::{KeyRef, VertexRef};
|
||||
use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
/// A vertex or tangent handle drawn this frame (masks and shape paths alike; `mask` is the path
|
||||
/// group's uid).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct VertexHit {
|
||||
pub layer: LayerId,
|
||||
pub mask: u64,
|
||||
pub index: usize,
|
||||
pub pos: Pos2,
|
||||
/// None = the vertex itself, Some(out?) = a tangent handle.
|
||||
pub tangent: Option<bool>,
|
||||
/// Path-space vertex position and path → comp matrix.
|
||||
pub vertex: [f64; 2],
|
||||
pub l2c: Mat3,
|
||||
}
|
||||
|
||||
/// A path drawn this frame (for segment hits and the free-transform box).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PathInfo {
|
||||
pub layer: LayerId,
|
||||
pub uid: u64,
|
||||
/// Path space → comp.
|
||||
pub m: Mat3,
|
||||
pub sp: ShapePath,
|
||||
pub is_mask: bool,
|
||||
}
|
||||
|
||||
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
|
||||
let u = 1.0 - t;
|
||||
let f = |k: usize| u * u * u * p0[k] + 3.0 * u * u * t * p1[k] + 3.0 * u * t * t * p2[k] + t * t * t * p3[k];
|
||||
[f(0), f(1)]
|
||||
}
|
||||
|
||||
/// Control points of segment `i` (vertex i → next) in path space.
|
||||
pub(crate) fn segment(sp: &ShapePath, i: usize) -> Option<[[f64; 2]; 4]> {
|
||||
let n = sp.vertices.len();
|
||||
let j = if i + 1 < n {
|
||||
i + 1
|
||||
} else if sp.closed && n > 1 {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let (a, d) = (sp.vertices[i], sp.vertices[j]);
|
||||
let to = sp.out_tangents.get(i).copied().unwrap_or_default();
|
||||
let ti = sp.in_tangents.get(j).copied().unwrap_or_default();
|
||||
Some([a, [a[0] + to[0], a[1] + to[1]], [d[0] + ti[0], d[1] + ti[1]], d])
|
||||
}
|
||||
|
||||
/// The path segment under a screen point: (layer, path uid, segment, t).
|
||||
pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64, usize, f64)> {
|
||||
let mut best: Option<(f32, (LayerId, u64, usize, f64))> = None;
|
||||
for p in paths {
|
||||
for i in 0..p.sp.vertices.len() {
|
||||
let Some(c) = segment(&p.sp, i) else { continue };
|
||||
for k in 1..48 {
|
||||
let t = k as f64 / 48.0;
|
||||
let q = cubic(c[0], c[1], c[2], c[3], t);
|
||||
let cq = p.m.apply(gv2(q[0], q[1]));
|
||||
let d = map.to_screen([cq.x, cq.y]).distance(pos);
|
||||
if d < tol && best.as_ref().is_none_or(|b| d < b.0) {
|
||||
best = Some((d, (p.layer, p.uid, i, t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// A mask or shape path of a layer by group uid, evaluated at the context time.
|
||||
pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathInfo> {
|
||||
let l = ectx.comp.layer(layer)?;
|
||||
let g = l.props.find_group(uid)?;
|
||||
let sp = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned())?;
|
||||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
|
||||
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
|
||||
Some(PathInfo { layer, uid, m, sp, is_mask })
|
||||
}
|
||||
|
||||
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
|
||||
let k = effectcraft_engine::render::kurbo_path(sp);
|
||||
let mut pts: Vec<Pos2> = vec![];
|
||||
kurbo::flatten(k.iter(), 0.5, |el| match el {
|
||||
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
|
||||
let c = m.apply(gv2(q.x, q.y));
|
||||
pts.push(map.to_screen([c.x, c.y]));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if sp.closed && !pts.is_empty() {
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Draw the masks and (on shape layers) shape paths of a selected layer with their vertices;
|
||||
/// selected vertices show their tangent handles. Collects vertex hits and paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn draw_paths(
|
||||
app: &mut EffectcraftApp,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
l: &Layer,
|
||||
label_col: Color32,
|
||||
sel: &[VertexRef],
|
||||
pen: Option<(LayerId, u64)>,
|
||||
hover: Option<Pos2>,
|
||||
hits: &mut Vec<VertexHit>,
|
||||
paths: &mut Vec<PathInfo>,
|
||||
) {
|
||||
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
|
||||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let mut list: Vec<(u64, String, ShapePath, Mat3, Color32, bool)> = vec![];
|
||||
if let Some(masks) = l.masks() {
|
||||
for g in masks.groups() {
|
||||
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
|
||||
if locked && app.session.state.hide_locked_masks {
|
||||
continue;
|
||||
}
|
||||
let Some(sp) = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned()) else { continue };
|
||||
list.push((g.uid, g.name.clone(), sp, m, Color32::from_rgb(color[0], color[1], color[2]), true));
|
||||
}
|
||||
}
|
||||
for (uid, sp) in effectcraft_engine::viewer::shape_paths(ectx, l) {
|
||||
let im = effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY);
|
||||
let name = l.props.find_group(uid).map(|g| g.name.clone()).unwrap_or_default();
|
||||
list.push((uid, name, sp, m * im, label_col, false));
|
||||
}
|
||||
for (uid, name, sp, pm, mc, is_mask) in list {
|
||||
painter.add(egui::Shape::line(flatten(&sp, &pm, map), Stroke::new(1.0, mc)));
|
||||
let scr = |p: [f64; 2]| {
|
||||
let c = pm.apply(gv2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
for (i, v) in sp.vertices.iter().enumerate() {
|
||||
let s = scr(*v);
|
||||
let selected = sel.iter().any(|x| x.layer == l.id && x.mask == uid && x.index == i);
|
||||
if selected {
|
||||
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
|
||||
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
|
||||
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, mc));
|
||||
painter.circle_filled(h, 3.0, mc);
|
||||
hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: pm });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
let kind = if is_mask { "mask" } else { "shapePath" };
|
||||
app.auto.add(&format!("viewer.{kind}.{uid}.vertex.{i}"), Rect::from_center_size(s, vec2(8.0, 8.0)), &name);
|
||||
hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: s, tangent: None, vertex: *v, l2c: pm });
|
||||
}
|
||||
// Pen rubber band from the last vertex to the pointer.
|
||||
if pen == Some((l.id, uid))
|
||||
&& let (Some(last), Some(hp)) = (sp.vertices.last(), hover)
|
||||
{
|
||||
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
|
||||
}
|
||||
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion paths
|
||||
|
||||
/// A keyframe dot or spatial tangent handle on a motion path.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct KeyHit {
|
||||
pub layer: LayerId,
|
||||
pub prop: u64,
|
||||
/// Layer time of the key.
|
||||
pub time: Tick,
|
||||
pub pos: Pos2,
|
||||
/// None = the key, Some(out?) = a tangent handle.
|
||||
pub tangent: Option<bool>,
|
||||
pub value: [f64; 3],
|
||||
/// Parent space → comp.
|
||||
pub p2c: Mat3,
|
||||
}
|
||||
|
||||
/// Parent space → comp matrix of a 2D layer at the context time.
|
||||
pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 {
|
||||
match l.parent.and_then(|p| ectx.comp.layer(p)) {
|
||||
Some(p) => {
|
||||
let w = ectx.world_matrix(p).0;
|
||||
Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]])
|
||||
}
|
||||
None => Mat3::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe
|
||||
/// squares (filled when selected) and spatial Bezier tangent handles. Works through parents.
|
||||
pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec<KeyHit>) {
|
||||
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
|
||||
if pos.keys.len() < 2 || l.is_3d() {
|
||||
return;
|
||||
}
|
||||
let p2c = parent_to_comp(ectx, l);
|
||||
let to_scr = |v: [f64; 3]| {
|
||||
let c = p2c.apply(gv2(v[0], v[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time);
|
||||
let fd = ectx.comp.frame_duration();
|
||||
let mut pts = vec![];
|
||||
let mut lt = lt0;
|
||||
while lt <= lt1 && pts.len() < 20_000 {
|
||||
pts.push(to_scr(pos.value_at(lt).as_vec3()));
|
||||
lt += fd;
|
||||
}
|
||||
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
|
||||
let faint = col.gamma_multiply(0.8);
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
|
||||
for q in &pts {
|
||||
painter.circle_filled(*q, 1.2, faint);
|
||||
}
|
||||
let sel = &app.session.state.selected_keys;
|
||||
for (i, k) in pos.keys.iter().enumerate() {
|
||||
let v = k.value.as_vec3();
|
||||
let s = to_scr(v);
|
||||
let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i);
|
||||
for (out, tg) in [(false, tin), (true, tout)] {
|
||||
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
|
||||
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
|
||||
continue;
|
||||
}
|
||||
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, col));
|
||||
painter.circle_filled(h, 3.0, col);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
}
|
||||
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
|
||||
if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) {
|
||||
painter.rect_filled(r, 0.0, col);
|
||||
} else {
|
||||
painter.rect_filled(r, 0.0, Color32::from_black_alpha(140));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- free transform
|
||||
|
||||
fn ft_id() -> egui::Id {
|
||||
egui::Id::new("viewer-free-transform")
|
||||
}
|
||||
|
||||
/// Enter Free Transform on a path (double-click a path with the Selection tool).
|
||||
pub(crate) fn begin_free_transform(ctx: &egui::Context, layer: LayerId, uid: u64) {
|
||||
ctx.data_mut(|d| d.insert_temp(ft_id(), (layer, uid)));
|
||||
}
|
||||
|
||||
pub(crate) fn end_free_transform(ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| d.remove::<(LayerId, u64)>(ft_id()));
|
||||
}
|
||||
|
||||
pub(crate) fn free_transform_target(ctx: &egui::Context) -> Option<(LayerId, u64)> {
|
||||
ctx.data(|d| d.get_temp(ft_id()))
|
||||
}
|
||||
|
||||
/// The free-transform box: path-space bounds of the transformed vertices, its handles on screen
|
||||
/// (corners 0–3, edge midpoints 4–7) and the path → comp matrix.
|
||||
pub(crate) struct FtBox {
|
||||
pub layer: LayerId,
|
||||
pub uid: u64,
|
||||
pub b: [f64; 4],
|
||||
pub m: Mat3,
|
||||
pub handles: [Pos2; 8],
|
||||
pub quad: [Pos2; 4],
|
||||
}
|
||||
|
||||
impl FtBox {
|
||||
/// Path-space position of handle `i`.
|
||||
pub fn handle_local(&self, i: usize) -> [f64; 2] {
|
||||
let [x0, y0, x1, y1] = self.b;
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [cx, y0], [x1, cy], [cx, y1], [x0, cy]][i]
|
||||
}
|
||||
pub fn center(&self) -> [f64; 2] {
|
||||
[(self.b[0] + self.b[2]) / 2.0, (self.b[1] + self.b[3]) / 2.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the free-transform box of the target path (if any) and return it for hit tests.
|
||||
pub(crate) fn draw_free_transform(
|
||||
app: &mut EffectcraftApp,
|
||||
ctx: &egui::Context,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
paths: &[PathInfo],
|
||||
) -> Option<FtBox> {
|
||||
let (layer, uid) = free_transform_target(ctx)?;
|
||||
let Some(p) = paths.iter().find(|p| p.layer == layer && p.uid == uid) else {
|
||||
end_free_transform(ctx);
|
||||
return None;
|
||||
};
|
||||
let sel: Vec<usize> = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect();
|
||||
let idx: Vec<usize> = if sel.len() >= 2 { sel } else { (0..p.sp.vertices.len()).collect() };
|
||||
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
|
||||
for &i in &idx {
|
||||
let Some(v) = p.sp.vertices.get(i) else { continue };
|
||||
lo = [lo[0].min(v[0]), lo[1].min(v[1])];
|
||||
hi = [hi[0].max(v[0]), hi[1].max(v[1])];
|
||||
}
|
||||
if lo[0] > hi[0] {
|
||||
return None;
|
||||
}
|
||||
let b = [lo[0], lo[1], hi[0].max(lo[0] + 1e-3), hi[1].max(lo[1] + 1e-3)];
|
||||
let mut f = FtBox { layer, uid, b, m: p.m, handles: [Pos2::ZERO; 8], quad: [Pos2::ZERO; 4] };
|
||||
for i in 0..8 {
|
||||
let q = f.handle_local(i);
|
||||
let c = p.m.apply(gv2(q[0], q[1]));
|
||||
f.handles[i] = map.to_screen([c.x, c.y]);
|
||||
}
|
||||
f.quad = [f.handles[0], f.handles[1], f.handles[2], f.handles[3]];
|
||||
let col = app.tokens.accent;
|
||||
painter.add(egui::Shape::closed_line(f.quad.to_vec(), Stroke::new(1.0, col)));
|
||||
for (i, h) in f.handles.iter().enumerate() {
|
||||
let r = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Outside);
|
||||
app.auto.add(&format!("viewer.freeTransform.handle.{i}"), r, "Free transform handle");
|
||||
}
|
||||
let c = f.center();
|
||||
let cc = p.m.apply(gv2(c[0], c[1]));
|
||||
let cs = map.to_screen([cc.x, cc.y]);
|
||||
painter.circle_stroke(cs, 4.0, Stroke::new(1.0, col));
|
||||
Some(f)
|
||||
}
|
||||
|
||||
fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
|
||||
let mut sign = 0.0f32;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
let c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||||
if c.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = c.signum();
|
||||
} else if c.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drags
|
||||
|
||||
/// Overlay drags owned by this module.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Drag {
|
||||
/// A motion-path keyframe: its value moves with the pointer (parent space).
|
||||
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
|
||||
/// A spatial tangent handle of a motion-path key.
|
||||
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
|
||||
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
|
||||
Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] },
|
||||
/// Mask Feather tool: drag away from the path to feather the mask.
|
||||
Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 },
|
||||
/// A guide dragged in the viewer.
|
||||
Guide { index: usize, vertical: bool },
|
||||
/// Drawing the region of interest.
|
||||
Roi { start: [f64; 2] },
|
||||
}
|
||||
|
||||
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
|
||||
pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: Pos2, cpt: [f64; 2], shift: bool) -> Option<Drag> {
|
||||
let h = hits
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|h| h.tangent.is_some())
|
||||
.find(|h| h.pos.distance(press) < 6.0)
|
||||
.or_else(|| hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(press) < 6.0))?;
|
||||
let inv = h.p2c.inverse().unwrap_or(Mat3::IDENTITY);
|
||||
match h.tangent {
|
||||
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
|
||||
None => {
|
||||
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
|
||||
if !app.session.state.selected_keys.contains(&k) || shift {
|
||||
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
|
||||
}
|
||||
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a free-transform drag on the box: a handle scales about the opposite one (corners both
|
||||
/// axes, edges one), inside moves, just outside a corner rotates about the centre.
|
||||
pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<Drag> {
|
||||
let inv = f.m.inverse()?;
|
||||
let c = map.to_comp(press);
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
if let Some(i) = (0..8).find(|i| f.handles[*i].distance(press) < 7.0) {
|
||||
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
|
||||
let axes = match i {
|
||||
4 | 6 => [false, true],
|
||||
5 | 7 => [true, false],
|
||||
_ => [true, true],
|
||||
};
|
||||
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 1, anchor: f.handle_local(opp), last: lp, inv, axes });
|
||||
}
|
||||
if point_in_quad(press, &f.quad) {
|
||||
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 0, anchor: f.center(), last: lp, inv, axes: [true, true] });
|
||||
}
|
||||
if f.quad.iter().any(|q| q.distance(press) < 24.0) {
|
||||
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 2, anchor: f.center(), last: lp, inv, axes: [true, true] });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mask Feather tool: start on a mask path.
|
||||
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
|
||||
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), map, press, 10.0)?;
|
||||
let l = ectx.comp.layer(layer)?;
|
||||
let pr = l.masks()?.find_group(uid)?.get("feather")?;
|
||||
let _ = app;
|
||||
Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press })
|
||||
}
|
||||
|
||||
/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp
|
||||
/// point for snapping. Returns the replacement drag state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn update(
|
||||
app: &mut EffectcraftApp,
|
||||
d: &Drag,
|
||||
cpt: [f64; 2],
|
||||
pos: Pos2,
|
||||
mods: egui::Modifiers,
|
||||
merge: &str,
|
||||
zoom: f32,
|
||||
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
|
||||
) -> Option<Drag> {
|
||||
match d.clone() {
|
||||
Drag::Key { layer, prop, time, start, press, inv } => {
|
||||
// Snap the key's on-screen position like a layer drag.
|
||||
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
|
||||
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
|
||||
let s = snap(app, target, layer);
|
||||
target = [target[0] + s[0], target[1] + s[1]];
|
||||
let p = inv.apply(gv2(target[0], target[1]));
|
||||
let v = json!([p.x, p.y, start[2]]);
|
||||
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
None
|
||||
}
|
||||
Drag::KeyTangent { layer, prop, time, out, key, inv } => {
|
||||
let p = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let tg = json!([p.x - key[0], p.y - key[1], 0.0]);
|
||||
let mut q = json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "break": mods.alt, "merge": merge});
|
||||
q[if out { "out" } else { "in" }] = tg;
|
||||
let _ = app.session.execute("keys.setSpatialTangents", q);
|
||||
None
|
||||
}
|
||||
Drag::Ft { layer, path, mode, anchor, last, inv, axes } => {
|
||||
let p = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let cur = [p.x, p.y];
|
||||
let mut q = json!({"layer": layer.0, "mask": path, "anchor": anchor, "merge": merge});
|
||||
match mode {
|
||||
0 => q["offset"] = json!([cur[0] - last[0], cur[1] - last[1]]),
|
||||
1 => {
|
||||
let f = |k: usize| {
|
||||
let a = last[k] - anchor[k];
|
||||
if axes[k] && a.abs() > 1e-6 { ((cur[k] - anchor[k]) / a).clamp(-50.0, 50.0) } else { 1.0 }
|
||||
};
|
||||
let (mut fx, mut fy) = (f(0), f(1));
|
||||
if mods.shift && axes[0] && axes[1] {
|
||||
let k = (fx + fy) / 2.0;
|
||||
(fx, fy) = (k, k);
|
||||
}
|
||||
if fx.abs() < 1e-3 || fy.abs() < 1e-3 {
|
||||
return None;
|
||||
}
|
||||
q["scale"] = json!([fx * 100.0, fy * 100.0]);
|
||||
}
|
||||
_ => {
|
||||
let a0 = (last[1] - anchor[1]).atan2(last[0] - anchor[0]);
|
||||
let a1 = (cur[1] - anchor[1]).atan2(cur[0] - anchor[0]);
|
||||
q["rotation"] = json!((a1 - a0).to_degrees());
|
||||
}
|
||||
}
|
||||
if cur != last {
|
||||
let _ = app.session.execute("path.freeTransform", q);
|
||||
}
|
||||
Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes })
|
||||
}
|
||||
Drag::Feather { layer, prop, start, press } => {
|
||||
let amt = (pos.distance(press) / zoom.max(1e-3)) as f64;
|
||||
let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge}));
|
||||
None
|
||||
}
|
||||
Drag::Guide { index, .. } => {
|
||||
let _ = index;
|
||||
None
|
||||
}
|
||||
Drag::Roi { .. } => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
//! On-canvas text editing in the Composition viewer (Type tool, double-click a text layer).
|
||||
//!
|
||||
//! - **Type tool**: click empty space for point text, drag for paragraph (box) text, click a text
|
||||
//! layer to put the caret there. Cmd+T toggles horizontal / vertical type.
|
||||
//! - **Editing**: caret at glyph boundaries, mouse click / drag selection, double-click selects a
|
||||
//! word and triple-click a line; arrows, Home / End and word / paragraph jumps (Cmd), Shift
|
||||
//! extends; typing, Backspace / Delete (Cmd or Alt: by word), Enter (new paragraph), Tab, Cmd+A,
|
||||
//! copy / cut / paste, IME composition; Alt+←/→ kerning (caret) or tracking (selection) ±20
|
||||
//! (Cmd: ±100); Cmd+Shift+< / > font size ±2; Cmd+Z / Cmd+Shift+Z undo / redo. Escape or
|
||||
//! Cmd+Enter commits. Paragraph text shows its box with handles that resize it.
|
||||
//! - Selection highlight, caret and box are drawn in comp space through the layer's transform
|
||||
//! (and parenting).
|
||||
//!
|
||||
//! Every change runs an engine command (`text.*`, `layer.setText`, `edit.*`), so it's undoable and
|
||||
//! agents can do the same over the control channel.
|
||||
|
||||
use effectcraft_engine::commands::text_edit::layer_doc;
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::keyframe::{Kerning, TextDoc};
|
||||
use effectcraft_engine::project::{Layer, LayerId, LayerSource};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::text::kurbo::Point;
|
||||
use effectcraft_engine::text::{TextLayout, layout_doc};
|
||||
use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::EffectcraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Pointer interaction owned by text editing (kept across frames while the button is down).
|
||||
#[derive(Clone, Debug)]
|
||||
enum Drag {
|
||||
/// Extending the selection from the press.
|
||||
Select { layer: LayerId },
|
||||
/// Type tool: a click (point text) or a drag (paragraph box) from `start` (comp space).
|
||||
Create { start: [f64; 2], vertical: bool },
|
||||
/// Resizing a paragraph box by one of its 8 handles.
|
||||
Resize { layer: LayerId, handle: usize, start_box: [f64; 4] },
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("viewer-text-drag")
|
||||
}
|
||||
fn focus_id() -> egui::Id {
|
||||
egui::Id::new("viewer-text-focus")
|
||||
}
|
||||
|
||||
/// The geometry of an edited text layer.
|
||||
struct Edited {
|
||||
layer: Layer,
|
||||
doc: TextDoc,
|
||||
lay: TextLayout,
|
||||
/// Layer → comp.
|
||||
m: Mat3,
|
||||
inv: Mat3,
|
||||
}
|
||||
|
||||
impl Edited {
|
||||
fn to_screen(&self, map: &ViewerMap, p: (f64, f64)) -> Pos2 {
|
||||
let q = self.m.apply(gv2(p.0, p.1));
|
||||
map.to_screen([q.x, q.y])
|
||||
}
|
||||
fn to_layer(&self, map: &ViewerMap, s: Pos2) -> (f64, f64) {
|
||||
let c = map.to_comp(s);
|
||||
let q = self.inv.apply(gv2(c[0], c[1]));
|
||||
(q.x, q.y)
|
||||
}
|
||||
/// The text's region in layer space: the box, or the laid-out lines.
|
||||
fn region(&self) -> [f64; 4] {
|
||||
if let Some(b) = self.doc.box_size {
|
||||
return [self.doc.box_pos[0], self.doc.box_pos[1], self.doc.box_pos[0] + b[0], self.doc.box_pos[1] + b[1]];
|
||||
}
|
||||
let b = self.lay.layout.bounds;
|
||||
let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| self.lay.to_layer * Point::new(x as f64, y as f64));
|
||||
let xs = corners.iter().map(|p| p.x);
|
||||
let ys = corners.iter().map(|p| p.y);
|
||||
let pad = 6.0;
|
||||
[
|
||||
xs.clone().fold(f64::INFINITY, f64::min) - pad,
|
||||
ys.clone().fold(f64::INFINITY, f64::min) - pad,
|
||||
xs.fold(f64::NEG_INFINITY, f64::max) + pad,
|
||||
ys.fold(f64::NEG_INFINITY, f64::max) + pad,
|
||||
]
|
||||
}
|
||||
fn contains(&self, map: &ViewerMap, s: Pos2) -> bool {
|
||||
let (x, y) = self.to_layer(map, s);
|
||||
let r = self.region();
|
||||
x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]
|
||||
}
|
||||
fn hit(&self, map: &ViewerMap, s: Pos2) -> usize {
|
||||
let (x, y) = self.to_layer(map, s);
|
||||
self.lay.hit(Point::new(x, y))
|
||||
}
|
||||
/// Box handle positions (layer space): corners and edge midpoints, clockwise from top-left.
|
||||
fn handles(&self) -> Vec<(f64, f64)> {
|
||||
let Some(b) = self.doc.box_size else { return vec![] };
|
||||
let (x0, y0) = (self.doc.box_pos[0], self.doc.box_pos[1]);
|
||||
let (x1, y1) = (x0 + b[0], y0 + b[1]);
|
||||
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
vec![(x0, y0), (mx, y0), (x1, y0), (x1, my), (x1, y1), (mx, y1), (x0, y1), (x0, my)]
|
||||
}
|
||||
}
|
||||
|
||||
fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited> {
|
||||
let layer = ectx.comp.layer(lid)?.clone();
|
||||
if !matches!(layer.source, LayerSource::Text) {
|
||||
return None;
|
||||
}
|
||||
let doc = layer_doc(&app.session, lid)?;
|
||||
let lay = layout_doc(&doc);
|
||||
let m = super::viewer::l2c(ectx, &layer).0;
|
||||
let inv = m.inverse()?;
|
||||
Some(Edited { layer, doc, lay, m, inv })
|
||||
}
|
||||
|
||||
/// The topmost text layer whose text is under screen point `s`.
|
||||
fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2) -> Option<Edited> {
|
||||
ectx.comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| matches!(l.source, LayerSource::Text) && l.is_active_at(ectx.time) && l.switches.video && !l.switches.locked)
|
||||
.filter_map(|l| edited(app, ectx, l.id))
|
||||
.find(|e| e.contains(map, s))
|
||||
}
|
||||
|
||||
fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option<Value> {
|
||||
match app.session.execute(id, p) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
app.ui.status = e.to_string();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing merge key: consecutive keystrokes fold into one undo step until the caret moves.
|
||||
fn typing_key(ui: &egui::Ui, bump: bool) -> String {
|
||||
let id = egui::Id::new("viewer-text-typing");
|
||||
let mut n: u64 = ui.data(|d| d.get_temp(id)).unwrap_or(0);
|
||||
if bump {
|
||||
n += 1;
|
||||
ui.data_mut(|d| d.insert_temp(id, n));
|
||||
}
|
||||
format!("text-typing-{n}")
|
||||
}
|
||||
|
||||
/// Viewer hook: draws the edit overlay and handles text pointer and keyboard input. Returns
|
||||
/// true when it owns this frame's pointer interaction (the viewer then skips its own gestures).
|
||||
pub fn hook(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
resp: &egui::Response,
|
||||
space_pan: bool,
|
||||
) -> bool {
|
||||
// The keyboard target: a focusable (non-clickable) widget, so egui and AccessKit know it.
|
||||
ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
|
||||
let tool = app.ui.tool;
|
||||
let type_tool = matches!(tool, Tool::Type | Tool::TypeVertical);
|
||||
// The session ends when its layer leaves the active comp.
|
||||
if let Some(e) = app.session.state.text_edit.clone()
|
||||
&& ectx.comp.layer(e.layer).is_none()
|
||||
{
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
let editing = app.session.state.text_edit.clone();
|
||||
let cur = editing.as_ref().and_then(|e| edited(app, ectx, e.layer));
|
||||
let mut owns = false;
|
||||
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
let (pressed, down, released, mods) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released(), i.modifiers));
|
||||
let press = ui.input(|i| i.pointer.press_origin());
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
let handle_hit = |e: &Edited, s: Pos2| e.handles().iter().position(|h| e.to_screen(map, *h).distance(s) < 7.0);
|
||||
|
||||
// ---- press
|
||||
if pressed
|
||||
&& !space_pan
|
||||
&& resp.hovered()
|
||||
&& !matches!(tool, Tool::Hand | Tool::Zoom)
|
||||
&& let Some(pos) = press
|
||||
{
|
||||
let mut handled = false;
|
||||
if let Some(e) = &cur {
|
||||
if let Some(h) = handle_hit(e, pos) {
|
||||
let b = e.doc.box_size.unwrap_or([0.0; 2]);
|
||||
drag = Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
|
||||
handled = true;
|
||||
} else if e.contains(map, pos) {
|
||||
let ci = e.hit(map, pos);
|
||||
let sel = editing.as_ref().expect("editing");
|
||||
let anchor = if mods.shift { sel.anchor } else { ci };
|
||||
exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
|
||||
typing_key(ui, true);
|
||||
drag = Some(Drag::Select { layer: e.layer.id });
|
||||
handled = true;
|
||||
} else {
|
||||
// Clicking elsewhere commits the edit.
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
}
|
||||
if !handled && type_tool {
|
||||
if let Some(e) = text_layer_at(app, ectx, map, pos) {
|
||||
let ci = e.hit(map, pos);
|
||||
exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
|
||||
drag = Some(Drag::Select { layer: e.layer.id });
|
||||
} else {
|
||||
drag = Some(Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical });
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
if handled {
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
owns = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- drag / release
|
||||
match drag.clone() {
|
||||
Some(Drag::Select { layer }) => {
|
||||
owns = true;
|
||||
if down
|
||||
&& let Some(p) = pointer
|
||||
&& let Some(e) = edited(app, ectx, layer)
|
||||
&& press.is_some_and(|q| q.distance(p) > 2.0)
|
||||
{
|
||||
let ci = e.hit(map, p);
|
||||
if app.session.state.text_edit.as_ref().is_some_and(|s| s.caret != ci) {
|
||||
let anchor = app.session.state.text_edit.as_ref().map_or(ci, |s| s.anchor);
|
||||
exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Drag::Create { start, vertical }) => {
|
||||
owns = true;
|
||||
let end = pointer.map(|p| map.to_comp(p)).unwrap_or(start);
|
||||
let a = map.to_screen(start);
|
||||
let b = map.to_screen(end);
|
||||
let is_box = a.distance(b) > 6.0;
|
||||
if is_box && down {
|
||||
painter.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle);
|
||||
}
|
||||
if released {
|
||||
let mut p = json!({"text": "", "edit": true, "size": 72, "fill": [1.0, 1.0, 1.0], "vertical": vertical});
|
||||
if is_box {
|
||||
let (x0, y0) = (start[0].min(end[0]), start[1].min(end[1]));
|
||||
p["box"] = json!([x0, y0, (end[0] - start[0]).abs(), (end[1] - start[1]).abs()]);
|
||||
} else {
|
||||
p["position"] = json!([start[0], start[1]]);
|
||||
p["justify"] = json!("left");
|
||||
}
|
||||
exec(app, "layer.newText", p);
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
}
|
||||
}
|
||||
Some(Drag::Resize { layer, handle, start_box }) => {
|
||||
owns = true;
|
||||
if down
|
||||
&& let (Some(p), Some(q)) = (pointer, press)
|
||||
&& let Some(e) = edited(app, ectx, layer)
|
||||
{
|
||||
let (px, py) = e.to_layer(map, p);
|
||||
let (qx, qy) = e.to_layer(map, q);
|
||||
let (dx, dy) = (px - qx, py - qy);
|
||||
let [mut x0, mut y0, w, h] = start_box;
|
||||
let (mut x1, mut y1) = (x0 + w, y0 + h);
|
||||
if matches!(handle, 0 | 6 | 7) {
|
||||
x0 += dx;
|
||||
}
|
||||
if matches!(handle, 2..=4) {
|
||||
x1 += dx;
|
||||
}
|
||||
if matches!(handle, 0..=2) {
|
||||
y0 += dy;
|
||||
}
|
||||
if matches!(handle, 4..=6) {
|
||||
y1 += dy;
|
||||
}
|
||||
let bx = [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)];
|
||||
exec(app, "layer.setText", json!({"layer": layer.0, "box": bx, "merge": format!("text-box-{}", layer.0)}));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Double / triple click: word / line; double-click a text layer to edit it.
|
||||
if resp.triple_clicked()
|
||||
&& let Some(p) = pointer
|
||||
&& let Some(e) = app.session.state.text_edit.clone().and_then(|s| edited(app, ectx, s.layer))
|
||||
&& e.contains(map, p)
|
||||
{
|
||||
let (a, b) = e.lay.line_span(e.hit(map, p));
|
||||
exec(app, "text.setSelection", json!({"anchor": a, "caret": b}));
|
||||
owns = true;
|
||||
} else if resp.double_clicked()
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let cur2 = app.session.state.text_edit.clone().and_then(|s| edited(app, ectx, s.layer));
|
||||
match cur2 {
|
||||
Some(e) if e.contains(map, p) => {
|
||||
let r = effectcraft_engine::keyframe::text_doc::word_at(&e.doc.text, e.hit(map, p));
|
||||
exec(app, "text.setSelection", json!({"anchor": r.start, "caret": r.end}));
|
||||
owns = true;
|
||||
}
|
||||
_ if !matches!(tool, Tool::Hand | Tool::Zoom) && !tool.is_pen() && !type_tool => {
|
||||
if let Some(e) = text_layer_at(app, ectx, map, p) {
|
||||
exec(app, "text.edit", json!({"layer": e.layer.id.0}));
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
owns = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if released || !down {
|
||||
ui.data_mut(|d| d.remove::<Drag>(drag_id()));
|
||||
} else if let Some(d) = drag {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
|
||||
}
|
||||
|
||||
// ---- keyboard
|
||||
if app.session.state.text_edit.is_some() {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
|
||||
});
|
||||
// A session started elsewhere (menu, agent, Properties) takes the keyboard once.
|
||||
let seen = egui::Id::new("viewer-text-seen");
|
||||
let key = app.session.state.text_edit.as_ref().map(|e| e.layer.0);
|
||||
// …and gets it back whenever nothing else holds it (after a panel click, say).
|
||||
if ui.data(|d| d.get_temp::<Option<u64>>(seen)).flatten() != key || ui.memory(|m| m.focused().is_none()) {
|
||||
ui.data_mut(|d| d.insert_temp(seen, key));
|
||||
ui.memory_mut(|m| m.request_focus(focus_id()));
|
||||
}
|
||||
if ui.memory(|m| m.has_focus(focus_id())) {
|
||||
keyboard(app, ui);
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp::<Option<u64>>(egui::Id::new("viewer-text-seen"), None));
|
||||
if ui.memory(|m| m.has_focus(focus_id())) {
|
||||
ui.memory_mut(|m| m.surrender_focus(focus_id()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- overlay
|
||||
if let Some(s) = app.session.state.text_edit.clone()
|
||||
&& let Some(e) = edited(app, ectx, s.layer)
|
||||
{
|
||||
draw(app, ui, painter, map, &e, s.range(), s.caret);
|
||||
}
|
||||
owns
|
||||
}
|
||||
|
||||
fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
let events = ui.input(|i| i.events.clone());
|
||||
let ctx = ui.ctx().clone();
|
||||
for ev in events {
|
||||
let Some(sel) = app.session.state.text_edit.clone() else { return };
|
||||
match ev {
|
||||
egui::Event::Text(t) if !t.chars().any(char::is_control) => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": t, "merge": k}));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) if !t.is_empty() => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": t, "merge": k}));
|
||||
ui.data_mut(|d| d.remove::<String>(egui::Id::new("viewer-text-preedit")));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-preedit"), text));
|
||||
}
|
||||
egui::Event::Copy => {
|
||||
if let Some(v) = exec(app, "edit.copy", json!({}))
|
||||
&& let Some(t) = v["text"].as_str()
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
}
|
||||
egui::Event::Cut => {
|
||||
if let Some(v) = exec(app, "edit.cut", json!({}))
|
||||
&& let Some(t) = v["text"].as_str()
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
typing_key(ui, true);
|
||||
}
|
||||
egui::Event::Paste(t) => {
|
||||
exec(app, "edit.paste", json!({"text": t}));
|
||||
typing_key(ui, true);
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
|
||||
use egui::Key;
|
||||
let cmd = m.command;
|
||||
let mv = |app: &mut EffectcraftApp, to: &str| {
|
||||
exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
|
||||
};
|
||||
match key {
|
||||
Key::ArrowLeft | Key::ArrowRight if m.alt => {
|
||||
// Kerning at the caret, tracking over a selection.
|
||||
let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
|
||||
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
|
||||
if sel.is_empty() {
|
||||
if sel.caret < doc.char_len() {
|
||||
let cur = match doc.style_at(sel.caret).kerning {
|
||||
Kerning::Manual(v) => v,
|
||||
_ => 0.0,
|
||||
};
|
||||
exec(
|
||||
app,
|
||||
"layer.setText",
|
||||
json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let r = sel.range();
|
||||
let cur = doc.style_at(r.start).tracking;
|
||||
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
|
||||
}
|
||||
}
|
||||
Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
|
||||
Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
|
||||
Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
|
||||
Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
|
||||
Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
|
||||
Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
|
||||
Key::Backspace => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k}));
|
||||
}
|
||||
Key::Delete => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k}));
|
||||
}
|
||||
Key::Enter if cmd => {
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
Key::Enter => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": "\n", "merge": k}));
|
||||
}
|
||||
Key::Tab => {
|
||||
let k = typing_key(ui, false);
|
||||
exec(app, "text.insert", json!({"text": "\t", "merge": k}));
|
||||
}
|
||||
Key::Escape => {
|
||||
exec(app, "text.endEdit", json!({}));
|
||||
}
|
||||
Key::A if cmd => {
|
||||
exec(app, "text.setSelection", json!({"select": "all"}));
|
||||
}
|
||||
Key::Z if cmd => {
|
||||
let _ = crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
|
||||
}
|
||||
Key::Comma | Key::Period if cmd && m.shift => {
|
||||
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
|
||||
let r = sel.range();
|
||||
let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
|
||||
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
|
||||
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
|
||||
typing_key(ui, true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &ViewerMap, e: &Edited, sel: std::ops::Range<usize>, caret: usize) {
|
||||
let t = app.tokens;
|
||||
let to_s = |p: Point| e.to_screen(map, (p.x, p.y));
|
||||
// Paragraph box and its handles.
|
||||
if e.doc.box_size.is_some() {
|
||||
let hs: Vec<Pos2> = e.handles().iter().map(|h| e.to_screen(map, *h)).collect();
|
||||
let corners = [hs[0], hs[2], hs[4], hs[6], hs[0]];
|
||||
for w in corners.windows(2) {
|
||||
painter.line_segment([w[0], w[1]], Stroke::new(1.0, Color32::from_white_alpha(170)));
|
||||
}
|
||||
for (i, h) in hs.iter().enumerate() {
|
||||
let r = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Middle);
|
||||
app.auto.add(&format!("viewer.textBox.handle.{i}"), r, "Text box handle");
|
||||
}
|
||||
}
|
||||
// Selection highlight.
|
||||
if !sel.is_empty() {
|
||||
for q in e.lay.selection_quads(sel.start, sel.end) {
|
||||
let pts: Vec<Pos2> = q.iter().map(|p| to_s(*p)).collect();
|
||||
painter.add(egui::Shape::convex_polygon(pts, t.accent.gamma_multiply(0.38), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
// Caret (blinking) and IME composition.
|
||||
let (a, b) = e.lay.caret(caret);
|
||||
let (sa, sb) = (to_s(a), to_s(b));
|
||||
let now = ui.input(|i| i.time);
|
||||
let preedit: Option<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));
|
||||
if let Some(pe) = preedit.filter(|s| !s.is_empty()) {
|
||||
let h = (sb - sa).length().max(10.0);
|
||||
let g = painter.layout_no_wrap(pe, egui::FontId::proportional(h * 0.7), Color32::WHITE);
|
||||
let r = Rect::from_min_size(sa, g.size());
|
||||
painter.rect_filled(r.expand(2.0), 2.0, Color32::from_black_alpha(160));
|
||||
painter.galley(sa, g, Color32::WHITE);
|
||||
painter.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, Color32::WHITE));
|
||||
} else if (now * 2.0) as i64 % 2 == 0 || !sel.is_empty() {
|
||||
painter.line_segment([sa, sb], Stroke::new(3.0, Color32::from_black_alpha(160)));
|
||||
painter.line_segment([sa, sb], Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
// The text region for automation, and the IME candidate window position.
|
||||
let r = e.region();
|
||||
let quad = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|p| e.to_screen(map, p));
|
||||
let rect = Rect::from_points(&quad);
|
||||
app.auto.add("viewer.textEdit", rect, &format!("Editing {}", e.layer.name));
|
||||
let cursor_rect = Rect::from_two_pos(sa, sb).expand2(vec2(1.0, 0.0));
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,689 @@
|
|||
//! Composition viewer tools: snapping (targets and the highlighted feedback), rulers with guides
|
||||
//! dragged out of them, the displayed frame (Show Channel, exposure, snapshots, region of
|
||||
//! interest, Wireframe Fast Previews) and the bottom control bar in After Effects' order.
|
||||
//!
|
||||
//! Edits go through engine commands (`view.*`, `comp.*`); the viewer calls in from small hooks.
|
||||
|
||||
use effectcraft_engine::commands::viewer_cmds::FastPreviews;
|
||||
use effectcraft_engine::project::{Comp, ItemId, LayerId};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_engine::viewer::{self as vw, Channel, Snap, SnapKind, SnapSource};
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::icons::Icon;
|
||||
use crate::state::Resolution;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
/// Snap distance in screen points.
|
||||
pub(crate) const SNAP_PX: f64 = 8.0;
|
||||
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
|
||||
|
||||
fn snap_id() -> egui::Id {
|
||||
egui::Id::new("viewer-snap")
|
||||
}
|
||||
|
||||
/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held.
|
||||
pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool {
|
||||
app.session.state.snapping != mods.command
|
||||
}
|
||||
|
||||
/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers'
|
||||
/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when
|
||||
/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns
|
||||
/// the comp-space correction (zero when nothing is near) and records the feedback for this frame.
|
||||
pub(crate) fn snap(
|
||||
app: &EffectcraftApp,
|
||||
ctx: &egui::Context,
|
||||
ectx: &EvalCtx,
|
||||
map: &ViewerMap,
|
||||
exclude: &[LayerId],
|
||||
sources: &[[f64; 2]],
|
||||
mods: egui::Modifiers,
|
||||
) -> [f64; 2] {
|
||||
let layers = snapping_on(app, mods);
|
||||
let v = &app.ui.viewer;
|
||||
let guides = v.snap_guides && v.guides;
|
||||
let grid = v.snap_grid;
|
||||
if sources.is_empty() || !(layers || guides || grid) {
|
||||
return [0.0; 2];
|
||||
}
|
||||
let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing };
|
||||
let mut targets = vw::targets(ectx, exclude, opts);
|
||||
if !layers {
|
||||
targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid));
|
||||
}
|
||||
match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) {
|
||||
Some(s) => {
|
||||
let d = s.delta;
|
||||
ctx.data_mut(|dd| dd.insert_temp(snap_id(), s));
|
||||
d
|
||||
}
|
||||
None => [0.0; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or
|
||||
/// grid line, and a cross where the feature landed.
|
||||
pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) {
|
||||
let Some(s) = ctx.data(|d| d.get_temp::<Snap>(snap_id())) else { return };
|
||||
ctx.data_mut(|d| d.remove::<Snap>(snap_id()));
|
||||
let stroke = Stroke::new(1.5, SNAP_COLOR);
|
||||
let area = map.area;
|
||||
for h in &s.hits {
|
||||
if let SnapSource::Layer(l) | SnapSource::Vertex(l) = h.source
|
||||
&& let Some(layer) = ectx.comp.layer(l)
|
||||
&& let Some(b) = effectcraft_engine::render::content_bounds(ectx, layer)
|
||||
{
|
||||
let (m, _) = ectx.layer_to_comp(layer);
|
||||
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::closed_line(q, stroke));
|
||||
}
|
||||
match h.kind {
|
||||
SnapKind::Point => {
|
||||
let c = map.to_screen(h.pos);
|
||||
painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle);
|
||||
}
|
||||
SnapKind::VLine => {
|
||||
let x = map.to_screen(h.pos).x;
|
||||
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR));
|
||||
}
|
||||
SnapKind::HLine => {
|
||||
let y = map.to_screen(h.pos).y;
|
||||
painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR));
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = map.to_screen(s.at);
|
||||
painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], stroke);
|
||||
painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], stroke);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rulers and guides
|
||||
|
||||
/// Ruler thickness in points.
|
||||
pub(crate) const RULER: f32 = 16.0;
|
||||
|
||||
/// The viewer area left for the image once the rulers (View ▸ Show Rulers) take their strips.
|
||||
pub(crate) fn inset(app: &EffectcraftApp, area: Rect) -> Rect {
|
||||
if app.ui.viewer.rulers { Rect::from_min_max(area.min + vec2(RULER, RULER), area.max) } else { area }
|
||||
}
|
||||
|
||||
fn nice(raw: f64) -> f64 {
|
||||
let mag = 10f64.powf(raw.max(1e-9).log10().floor());
|
||||
let n = raw / mag;
|
||||
mag * if n <= 1.0 {
|
||||
1.0
|
||||
} else if n <= 2.0 {
|
||||
2.0
|
||||
} else if n <= 5.0 {
|
||||
5.0
|
||||
} else {
|
||||
10.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the rulers (ticks relative to the zero point, live pointer markers) and handle their
|
||||
/// gestures: drag from a ruler to add a guide, drag the corner to move the zero point
|
||||
/// (double-click resets it).
|
||||
pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap, outer: Rect, inner: Rect) {
|
||||
if !app.ui.viewer.rulers {
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
let top = Rect::from_min_max(pos2(inner.min.x, outer.min.y), pos2(inner.max.x, inner.min.y));
|
||||
let left = Rect::from_min_max(pos2(outer.min.x, inner.min.y), pos2(inner.min.x, inner.max.y));
|
||||
let corner = Rect::from_min_max(outer.min, inner.min);
|
||||
let bg = t.panel_bg;
|
||||
for r in [top, left, corner] {
|
||||
p.rect_filled(r, 0.0, bg);
|
||||
}
|
||||
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
let o = app.ui.viewer.ruler_origin;
|
||||
let zoom = map.zoom as f64;
|
||||
let step = nice(64.0 / zoom.max(1e-6));
|
||||
let minor = step / if (step / nice(step / 2.0) - 2.0).abs() < 1e-9 { 2.0 } else { 5.0 };
|
||||
let tick = Stroke::new(1.0, t.text_faint);
|
||||
let font = Tokens::ui(9.0);
|
||||
let label = |v: f64| if step >= 1.0 { format!("{}", v.round() as i64) } else { format!("{v:.1}") };
|
||||
// Horizontal ruler (x).
|
||||
let pt = p.with_clip_rect(top);
|
||||
let (c0, c1) = (map.to_comp(top.left_top())[0], map.to_comp(top.right_top())[0]);
|
||||
let mut k = ((c0 - o[0]) / minor).floor() as i64;
|
||||
while (o[0] + k as f64 * minor) <= c1 {
|
||||
let cx = o[0] + k as f64 * minor;
|
||||
let x = map.to_screen([cx, 0.0]).x;
|
||||
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
|
||||
let is_major = major.abs() < minor * 0.01;
|
||||
pt.line_segment([pos2(x, top.max.y - if is_major { RULER - 2.0 } else { 4.0 }), pos2(x, top.max.y)], tick);
|
||||
if is_major {
|
||||
pt.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, label(cx - o[0]), font.clone(), t.text_dim);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
// Vertical ruler (y).
|
||||
let pl = p.with_clip_rect(left);
|
||||
let (r0, r1) = (map.to_comp(left.left_top())[1], map.to_comp(left.left_bottom())[1]);
|
||||
let mut k = ((r0 - o[1]) / minor).floor() as i64;
|
||||
while (o[1] + k as f64 * minor) <= r1 {
|
||||
let cy = o[1] + k as f64 * minor;
|
||||
let y = map.to_screen([0.0, cy]).y;
|
||||
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
|
||||
let is_major = major.abs() < minor * 0.01;
|
||||
pl.line_segment([pos2(left.max.x - if is_major { RULER - 2.0 } else { 4.0 }, y), pos2(left.max.x, y)], tick);
|
||||
if is_major {
|
||||
let g = pl.layout_no_wrap(label(cy - o[1]), font.clone(), t.text_dim);
|
||||
pl.add(egui::epaint::TextShape::new(pos2(left.min.x + 1.0, y + g.size().x + 2.0), g, t.text_dim).with_angle(-std::f32::consts::FRAC_PI_2));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
// Live pointer markers.
|
||||
if let Some(hp) = ui.ctx().pointer_hover_pos()
|
||||
&& inner.contains(hp)
|
||||
{
|
||||
let m = Stroke::new(1.0, t.accent);
|
||||
pt.line_segment([pos2(hp.x, top.min.y), pos2(hp.x, top.max.y)], m);
|
||||
pl.line_segment([pos2(left.min.x, hp.y), pos2(left.max.x, hp.y)], m);
|
||||
}
|
||||
// Zero point: drag the corner, double-click to reset.
|
||||
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick);
|
||||
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick);
|
||||
let cr =
|
||||
ui.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag()).on_hover_text("Ruler zero point (drag to move, double-click to reset)");
|
||||
app.auto.add("viewer.ruler.origin", corner, "Ruler zero point");
|
||||
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg")));
|
||||
if cr.dragged()
|
||||
&& let Some(hp) = cr.interact_pointer_pos()
|
||||
{
|
||||
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], Stroke::new(1.0, t.accent));
|
||||
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], Stroke::new(1.0, t.accent));
|
||||
}
|
||||
if cr.drag_stopped()
|
||||
&& let Some(hp) = cr.interact_pointer_pos()
|
||||
{
|
||||
let c = map.to_comp(hp);
|
||||
app.ui.viewer.ruler_origin = [c[0].round(), c[1].round()];
|
||||
}
|
||||
if cr.double_clicked() {
|
||||
app.ui.viewer.ruler_origin = [0.0, 0.0];
|
||||
}
|
||||
// Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one
|
||||
// vertical guides.
|
||||
for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text("Drag to create a guide");
|
||||
app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" });
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(hp) = resp.interact_pointer_pos()
|
||||
{
|
||||
let s = Stroke::new(1.0, guide_color(app));
|
||||
if vertical {
|
||||
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], s);
|
||||
} else {
|
||||
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], s);
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(hp) = resp.interact_pointer_pos()
|
||||
&& inner.contains(hp)
|
||||
{
|
||||
let c = map.to_comp(hp);
|
||||
let pos = if vertical { c[0] } else { c[1] }.round();
|
||||
let r = app.session.execute("view.addGuide", json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "position": pos}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.ui.viewer.guides = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guide_color(app: &EffectcraftApp) -> Color32 {
|
||||
let [r, g, b] = super::viewer::hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// The guide under a screen point (visible, unlocked guides only): (index, vertical).
|
||||
pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos: Pos2) -> Option<(usize, bool)> {
|
||||
if !app.ui.viewer.guides || app.ui.viewer.lock_guides {
|
||||
return None;
|
||||
}
|
||||
comp.guides.iter().enumerate().find_map(|(i, g)| {
|
||||
let s = map.to_screen([g.position, g.position]);
|
||||
let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() };
|
||||
(d < 4.0).then_some((i, g.vertical))
|
||||
})
|
||||
}
|
||||
|
||||
/// Proportional grid (Grid and guide options ▸ Proportional Grid): Settings ▸ Grids & Guides
|
||||
/// horizontal × vertical cells over the comp.
|
||||
pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, comp_rect: Rect) {
|
||||
if !app.ui.viewer.proportional_grid {
|
||||
return;
|
||||
}
|
||||
let g = &app.session.prefs.grids;
|
||||
let [r, gg, b] = super::viewer::hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
|
||||
let s = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 110));
|
||||
let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1));
|
||||
for i in 1..nx {
|
||||
let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32;
|
||||
painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s);
|
||||
}
|
||||
for i in 1..ny {
|
||||
let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32;
|
||||
painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the displayed frame
|
||||
|
||||
/// Remember the region of interest the displayed texture was rendered with.
|
||||
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi));
|
||||
}
|
||||
|
||||
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
|
||||
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
|
||||
}
|
||||
|
||||
/// Show Snapshot: the stored snapshot is on screen (button or F5 held).
|
||||
pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
|
||||
let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input();
|
||||
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
|
||||
}
|
||||
|
||||
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage {
|
||||
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
|
||||
vw::display_transform(&mut px, ch, colorized, stops);
|
||||
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
|
||||
}
|
||||
|
||||
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
|
||||
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
|
||||
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
|
||||
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
|
||||
let opts = app.session.state.viewer.clone();
|
||||
let snap = showing_snapshot(app, ctx);
|
||||
if opts.fast_previews == FastPreviews::Wireframe && !snap {
|
||||
let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32;
|
||||
let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect };
|
||||
for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) {
|
||||
let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue };
|
||||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0;
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
if snap {
|
||||
let Some(s) = app.session.snapshot.clone() else { return };
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-snapshot-tex");
|
||||
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
let img = crate::frames::to_color_image(&s.image);
|
||||
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) };
|
||||
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
tex
|
||||
}
|
||||
};
|
||||
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
|
||||
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
|
||||
return;
|
||||
}
|
||||
let Some((tex, k)) = app.viewer_tex.clone() else { return };
|
||||
if k.comp != cid.0 {
|
||||
return;
|
||||
}
|
||||
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
|
||||
let rect = match texture_roi(ctx) {
|
||||
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
|
||||
None => comp_rect,
|
||||
};
|
||||
if plain {
|
||||
painter.image(tex.id(), rect, uv, Color32::WHITE);
|
||||
return;
|
||||
}
|
||||
let Some(src) = app.viewer_image.clone() else { return };
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-display-tex");
|
||||
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
tex
|
||||
}
|
||||
};
|
||||
painter.image(tex.id(), rect, uv, Color32::WHITE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bottom bar
|
||||
|
||||
/// A popup list above `anchor` (selected entries highlighted); entries register
|
||||
/// `viewer.<auto>.<n>` automation ids.
|
||||
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
|
||||
let pid = egui::Id::new(id);
|
||||
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
|
||||
if !open {
|
||||
return None;
|
||||
}
|
||||
let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::<f32>() + 14.0;
|
||||
let pos = anchor.left_top() - vec2(0.0, h + 4.0);
|
||||
let mut chosen = None;
|
||||
let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(170.0);
|
||||
for (i, (label, on)) in items.iter().enumerate() {
|
||||
if label == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
let r = ui.selectable_label(*on, label.as_str());
|
||||
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
|
||||
if r.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let outside = ui.input(|i| i.pointer.any_pressed())
|
||||
&& !area.response.contains_pointer()
|
||||
&& !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default());
|
||||
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.data_mut(|d| d.insert_temp(pid.with("open"), false));
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
fn toggle_popup(ui: &egui::Ui, id: &str) {
|
||||
let pid = egui::Id::new(id).with("open");
|
||||
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
|
||||
ui.data_mut(|d| d.insert_temp(pid, !open));
|
||||
}
|
||||
|
||||
/// Magnification as After Effects shows it: "136.1 %", "100 %".
|
||||
pub(crate) fn magnification_label(pct: f32) -> String {
|
||||
if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") }
|
||||
}
|
||||
|
||||
/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets.
|
||||
pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String {
|
||||
match res {
|
||||
Resolution::Auto => {
|
||||
let r = if scale >= 0.99 {
|
||||
"Full"
|
||||
} else if scale >= 0.49 {
|
||||
"Half"
|
||||
} else if scale >= 0.33 {
|
||||
"Third"
|
||||
} else {
|
||||
"Quarter"
|
||||
};
|
||||
format!("({r})")
|
||||
}
|
||||
Resolution::Custom(n) => format!("Custom ({n})"),
|
||||
r => r.label().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution,
|
||||
/// transparency grid, mask and shape path visibility, region of interest, grid and guide
|
||||
/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D
|
||||
/// renderer and view, and the current time.
|
||||
pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(bar, 0.0, t.panel_bg);
|
||||
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
|
||||
let cy = bar.center().y;
|
||||
let mut x = bar.min.x + 8.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
|
||||
// Magnification.
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
|
||||
if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")).on_hover_text("Magnification ratio popup").clicked() {
|
||||
toggle_popup(ui, "vw-mag-pop");
|
||||
}
|
||||
app.auto.add("viewer.magnification", r, "Magnification");
|
||||
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
|
||||
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
|
||||
let items: Vec<(String, bool)> = MAGS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let on = match *m {
|
||||
"Fit" => app.ui.viewer.zoom.is_none(),
|
||||
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06),
|
||||
};
|
||||
(m.to_string(), on)
|
||||
})
|
||||
.collect();
|
||||
if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") {
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
app.ui.viewer.zoom = match MAGS[i] {
|
||||
"Fit" => None,
|
||||
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
|
||||
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
|
||||
};
|
||||
}
|
||||
x = r.max.x + 4.0;
|
||||
|
||||
// Resolution / down-sample factor.
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
|
||||
let scale = app.viewer_scale(zoom, ppp);
|
||||
if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
|
||||
.on_hover_text("Resolution/Down Sample Factor Popup")
|
||||
.clicked()
|
||||
{
|
||||
toggle_popup(ui, "vw-res-pop");
|
||||
}
|
||||
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
|
||||
let res = app.ui.viewer.res;
|
||||
let items: Vec<(String, bool)> = vec![
|
||||
("Full".into(), res == Resolution::Full),
|
||||
("Half".into(), res == Resolution::Half),
|
||||
("Third".into(), res == Resolution::Third),
|
||||
("Quarter".into(), res == Resolution::Quarter),
|
||||
("Custom...".into(), matches!(res, Resolution::Custom(_))),
|
||||
("-".into(), false),
|
||||
("Auto".into(), res == Resolution::Auto),
|
||||
];
|
||||
if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") {
|
||||
let ctx = ui.ctx().clone();
|
||||
let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i];
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
|
||||
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) {
|
||||
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip);
|
||||
auto.add(&format!("viewer.{id}"), r, tip);
|
||||
*x += 24.0;
|
||||
(resp.clicked(), r)
|
||||
};
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 {
|
||||
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 {
|
||||
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
|
||||
}
|
||||
// Region of interest: with none, arm drawing (the next drag draws it); with one, clear it.
|
||||
let has_roi = app.session.state.region_of_interest.is_some();
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 {
|
||||
if has_roi {
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null}));
|
||||
app.ui.viewer.roi_draw = false;
|
||||
} else {
|
||||
app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw;
|
||||
}
|
||||
}
|
||||
// Grid and guide options.
|
||||
let v = &app.ui.viewer;
|
||||
let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers;
|
||||
let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options");
|
||||
if clicked {
|
||||
toggle_popup(ui, "vw-grid-pop");
|
||||
}
|
||||
let v = &app.ui.viewer;
|
||||
let items = vec![
|
||||
("Title/Action Safe".to_string(), v.safe_margins),
|
||||
("Proportional Grid".to_string(), v.proportional_grid),
|
||||
("Grid".to_string(), v.grid),
|
||||
("Guides".to_string(), v.guides),
|
||||
("Rulers".to_string(), v.rulers),
|
||||
];
|
||||
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
|
||||
let v = &mut app.ui.viewer;
|
||||
match i {
|
||||
0 => v.safe_margins = !v.safe_margins,
|
||||
1 => v.proportional_grid = !v.proportional_grid,
|
||||
2 => v.grid = !v.grid,
|
||||
3 => v.guides = !v.guides,
|
||||
_ => v.rulers = !v.rulers,
|
||||
}
|
||||
}
|
||||
// Show Channel and Color Management Settings.
|
||||
let vo = app.session.state.viewer.clone();
|
||||
let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings");
|
||||
if clicked {
|
||||
toggle_popup(ui, "vw-channel-pop");
|
||||
}
|
||||
let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect();
|
||||
items.push(("-".into(), false));
|
||||
items.push(("Colorize".into(), vo.colorized));
|
||||
if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") {
|
||||
let params =
|
||||
if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) };
|
||||
let _ = app.session.execute("view.channel", params);
|
||||
}
|
||||
// Exposure.
|
||||
let exposed = vo.exposure != 0.0;
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 {
|
||||
let _ = app.session.execute("view.resetExposure", json!({}));
|
||||
}
|
||||
let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
|
||||
let mut ev = vo.exposure as f64;
|
||||
let resp = ui.put(
|
||||
er,
|
||||
egui::DragValue::new(&mut ev)
|
||||
.speed(0.05)
|
||||
.range(-40.0..=40.0)
|
||||
.fixed_decimals(1)
|
||||
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
|
||||
);
|
||||
let resp = resp.on_hover_text("Adjust Exposure (stops)");
|
||||
app.auto.add("viewer.exposure", er, "Adjust Exposure");
|
||||
if resp.changed() {
|
||||
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
|
||||
}
|
||||
x = er.max.x + 4.0;
|
||||
// Snapshots.
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
|
||||
let scale = app.viewer_scale(zoom, ppp);
|
||||
match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) {
|
||||
Ok(_) => app.ui.status = "Snapshot taken".into(),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
let showing = showing_snapshot(app, ui.ctx());
|
||||
let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)");
|
||||
// Show Snapshot works while the button is held, like F5.
|
||||
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o));
|
||||
if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot {
|
||||
let _ = app.session.execute("view.showSnapshot", json!({"value": held}));
|
||||
}
|
||||
// Fast Previews.
|
||||
let fp = vo.fast_previews;
|
||||
let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews");
|
||||
if clicked {
|
||||
toggle_popup(ui, "vw-fast-pop");
|
||||
}
|
||||
let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect();
|
||||
if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") {
|
||||
let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()}));
|
||||
}
|
||||
x += 4.0;
|
||||
// 3D renderer + view.
|
||||
if comp.has_3d() {
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
|
||||
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
|
||||
app.auto.add("viewer.renderer3d", r, "3D Renderer");
|
||||
x = r.max.x + 4.0;
|
||||
let cid = app.session.active_comp_id();
|
||||
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
|
||||
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
|
||||
toggle_popup(ui, "vw-cam-pop");
|
||||
}
|
||||
app.auto.add("viewer.view3d", r, "3D View");
|
||||
use effectcraft_engine::render::three_d::View3D;
|
||||
let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect();
|
||||
if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") {
|
||||
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
}
|
||||
// Current time.
|
||||
let tc = crate::panels::timecode(&app.session, comp, time);
|
||||
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
|
||||
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
|
||||
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
|
||||
app.auto.add("viewer.timecode", tr, "Current time");
|
||||
// Right: render time.
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bar_labels_match_after_effects() {
|
||||
assert_eq!(magnification_label(136.08), "136.1 %");
|
||||
assert_eq!(magnification_label(100.0), "100 %");
|
||||
assert_eq!(magnification_label(49.97), "50 %");
|
||||
assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)");
|
||||
assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)");
|
||||
assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter");
|
||||
assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,10 @@ pub enum Tool {
|
|||
Polygon,
|
||||
Star,
|
||||
Pen,
|
||||
PenAdd,
|
||||
PenDelete,
|
||||
PenConvert,
|
||||
MaskFeather,
|
||||
Type,
|
||||
TypeVertical,
|
||||
Brush,
|
||||
|
|
@ -49,7 +53,7 @@ impl Tool {
|
|||
&[Tool::Rotate],
|
||||
&[Tool::PanBehind],
|
||||
&[Tool::Rectangle, Tool::RoundedRect, Tool::Ellipse, Tool::Polygon, Tool::Star],
|
||||
&[Tool::Pen],
|
||||
&[Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather],
|
||||
&[Tool::Type, Tool::TypeVertical],
|
||||
&[Tool::Brush],
|
||||
&[Tool::Clone],
|
||||
|
|
@ -57,7 +61,7 @@ impl Tool {
|
|||
&[Tool::RotoBrush],
|
||||
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
|
||||
];
|
||||
pub const ALL: [Tool; 25] = [
|
||||
pub const ALL: [Tool; 29] = [
|
||||
Tool::Selection,
|
||||
Tool::Hand,
|
||||
Tool::Zoom,
|
||||
|
|
@ -72,6 +76,10 @@ impl Tool {
|
|||
Tool::Polygon,
|
||||
Tool::Star,
|
||||
Tool::Pen,
|
||||
Tool::PenAdd,
|
||||
Tool::PenDelete,
|
||||
Tool::PenConvert,
|
||||
Tool::MaskFeather,
|
||||
Tool::Type,
|
||||
Tool::TypeVertical,
|
||||
Tool::Brush,
|
||||
|
|
@ -100,6 +108,10 @@ impl Tool {
|
|||
Tool::Polygon => "Polygon Tool",
|
||||
Tool::Star => "Star Tool",
|
||||
Tool::Pen => "Pen Tool",
|
||||
Tool::PenAdd => "Add Vertex Tool",
|
||||
Tool::PenDelete => "Delete Vertex Tool",
|
||||
Tool::PenConvert => "Convert Vertex Tool",
|
||||
Tool::MaskFeather => "Mask Feather Tool",
|
||||
Tool::Type => "Horizontal Type Tool",
|
||||
Tool::TypeVertical => "Vertical Type Tool",
|
||||
Tool::Brush => "Brush Tool",
|
||||
|
|
@ -124,7 +136,7 @@ impl Tool {
|
|||
Tool::Rotate => Some("W"),
|
||||
Tool::PanBehind => Some("Y"),
|
||||
Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => Some("Q"),
|
||||
Tool::Pen => Some("G"),
|
||||
Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"),
|
||||
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
|
||||
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
|
||||
Tool::RotoBrush => Some("Alt+W"),
|
||||
|
|
@ -147,6 +159,10 @@ impl Tool {
|
|||
Tool::Polygon => Icon::Polygon,
|
||||
Tool::Star => Icon::Star,
|
||||
Tool::Pen => Icon::Pen,
|
||||
Tool::PenAdd => Icon::PenAdd,
|
||||
Tool::PenDelete => Icon::PenDelete,
|
||||
Tool::PenConvert => Icon::PenConvert,
|
||||
Tool::MaskFeather => Icon::MaskFeather,
|
||||
Tool::Type => Icon::Type,
|
||||
Tool::TypeVertical => Icon::TypeVertical,
|
||||
Tool::Brush => Icon::Brush,
|
||||
|
|
@ -180,6 +196,10 @@ impl Tool {
|
|||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The Pen slot's tools (Pen, Add / Delete / Convert Vertex, Mask Feather).
|
||||
pub fn is_pen(self) -> bool {
|
||||
matches!(self, Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather)
|
||||
}
|
||||
pub fn is_shape(self) -> bool {
|
||||
matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star)
|
||||
}
|
||||
|
|
@ -194,6 +214,8 @@ pub enum Resolution {
|
|||
Half,
|
||||
Third,
|
||||
Quarter,
|
||||
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
|
||||
Custom(u8),
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
|
|
@ -205,6 +227,7 @@ impl Resolution {
|
|||
Resolution::Half => "Half",
|
||||
Resolution::Third => "Third",
|
||||
Resolution::Quarter => "Quarter",
|
||||
Resolution::Custom(_) => "Custom",
|
||||
}
|
||||
}
|
||||
/// Render scale for a viewer magnification (and display pixel density).
|
||||
|
|
@ -226,6 +249,7 @@ impl Resolution {
|
|||
Resolution::Half => 0.5,
|
||||
Resolution::Third => 1.0 / 3.0,
|
||||
Resolution::Quarter => 0.25,
|
||||
Resolution::Custom(n) => 1.0 / n.max(1) as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -251,6 +275,15 @@ pub struct ViewerState {
|
|||
pub snap_guides: bool,
|
||||
pub lock_guides: bool,
|
||||
pub snap_grid: bool,
|
||||
/// Grid and guide options ▸ Proportional Grid.
|
||||
pub proportional_grid: bool,
|
||||
/// Rulers zero point (comp pixels), moved by dragging the ruler corner.
|
||||
pub ruler_origin: [f64; 2],
|
||||
/// Region of Interest button armed: the next drag in the viewer draws the region.
|
||||
pub roi_draw: bool,
|
||||
/// The pointer is dragging in the viewer this frame (Adaptive Resolution).
|
||||
#[serde(skip)]
|
||||
pub interacting: bool,
|
||||
/// View ▸ Panel Background Color (`None` = theme default).
|
||||
pub pasteboard: Option<[u8; 3]>,
|
||||
pub custom_pasteboard: [u8; 3],
|
||||
|
|
@ -274,6 +307,10 @@ impl Default for ViewerState {
|
|||
snap_guides: false,
|
||||
lock_guides: false,
|
||||
snap_grid: false,
|
||||
proportional_grid: false,
|
||||
ruler_origin: [0.0, 0.0],
|
||||
roi_draw: false,
|
||||
interacting: false,
|
||||
pasteboard: None,
|
||||
custom_pasteboard: [0x80, 0x80, 0x80],
|
||||
}
|
||||
|
|
@ -311,6 +348,15 @@ pub struct TimelineState {
|
|||
/// Manual graph value range (when auto-zoom is off).
|
||||
#[serde(default)]
|
||||
pub graph_range: Option<(f64, f64)>,
|
||||
/// Graph Editor ▸ Snap: key drags snap to the current time, other keys and frames.
|
||||
#[serde(default = "yes")]
|
||||
pub graph_snap: bool,
|
||||
/// Graph Editor ▸ Show Reference Graph (the other graph type, faint, behind).
|
||||
#[serde(default)]
|
||||
pub graph_reference: bool,
|
||||
/// Graph Editor ▸ Show Transform Box When Multiple Keys Are Selected.
|
||||
#[serde(default = "yes")]
|
||||
pub graph_transform_box: bool,
|
||||
/// Properties whose inline expression editor is collapsed.
|
||||
#[serde(default)]
|
||||
pub expr_closed: BTreeSet<u64>,
|
||||
|
|
@ -341,6 +387,9 @@ impl Default for TimelineState {
|
|||
graph_show_selected: true,
|
||||
graph_auto_zoom: true,
|
||||
graph_range: None,
|
||||
graph_snap: true,
|
||||
graph_reference: false,
|
||||
graph_transform_box: true,
|
||||
expr_closed: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The viewer on the GPU compositor (egui_kittest with wgpu): frames are drawn from GPU textures
|
||||
//! and match the CPU viewer. Skips without a GPU adapter (CI). Set `EC_SNAPSHOT_DIR` to keep PNGs.
|
||||
//! The viewer on the GPU compositor (egui_kittest with wgpu): frames composited on the GPU match
|
||||
//! the CPU viewer. Skips without a GPU adapter (CI). Set `EC_SNAPSHOT_DIR` to keep PNGs.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
|
|
@ -31,13 +31,11 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() {
|
|||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s));
|
||||
settle(&mut h);
|
||||
assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device");
|
||||
assert!(h.state().viewer_on_gpu(), "viewer frame comes from the GPU");
|
||||
let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame");
|
||||
let gpu_shot = h.render().expect("render");
|
||||
// Mercury Software Only: the same frame from the CPU.
|
||||
h.state_mut().session.execute("render.backend", json!({"backend": "cpu"})).unwrap();
|
||||
settle(&mut h);
|
||||
assert!(!h.state().viewer_on_gpu());
|
||||
let cpu_px = h.state_mut().viewer_pixels().expect("CPU frame");
|
||||
let cpu_shot = h.render().expect("render");
|
||||
assert_eq!(gpu_px.size, cpu_px.size);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Headless checks for on-canvas text editing in the viewer and the Character / Paragraph panels
|
||||
//! acting on the selection (egui_kittest, UI logic only: no GPU needed).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::text_edit::layer_doc;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
EffectcraftApp::new(s)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness<'_, EffectcraftApp>, t: &str) {
|
||||
h.input_mut().events.push(Event::Text(t.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn edited(h: &Harness<'_, EffectcraftApp>) -> (u64, String, usize, usize) {
|
||||
let s = &h.state().session;
|
||||
let e = s.state.text_edit.clone().expect("editing");
|
||||
(e.layer.0, layer_doc(s, e.layer).unwrap().text, e.anchor, e.caret)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_click_type_edit_and_commit() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
|
||||
h.run_steps(1);
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
// Click: a point text layer in edit mode, caret ready.
|
||||
click(&mut h, comp.center(), Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1);
|
||||
type_text(&mut h, "Hello");
|
||||
type_text(&mut h, " world");
|
||||
let (lid, text, a, c) = edited(&h);
|
||||
assert_eq!((text.as_str(), a, c), ("Hello world", 11, 11));
|
||||
assert!(h.state().auto.find("viewer.textEdit").is_some(), "edit region registered");
|
||||
// Arrow keys, Shift-extend, Backspace.
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::SHIFT);
|
||||
assert_eq!((edited(&h).2, edited(&h).3), (10, 9));
|
||||
key(&mut h, Key::Backspace, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).1, "Hello word");
|
||||
// Word jump and Home / End.
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).3, 0);
|
||||
key(&mut h, Key::ArrowRight, Modifiers::COMMAND);
|
||||
assert_eq!(edited(&h).3, 5);
|
||||
key(&mut h, Key::End, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).3, 10);
|
||||
// Enter starts a new paragraph.
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
type_text(&mut h, "two");
|
||||
assert_eq!(edited(&h).1, "Hello word\ntwo");
|
||||
// Select all, then the Character panel styles the selection only.
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert!(edited(&h).3 <= 10, "up to the first line");
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
key(&mut h, Key::End, Modifiers::SHIFT);
|
||||
let (_, _, a, c) = edited(&h);
|
||||
assert_eq!((a, c), (0, 10));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
let bold = rect(&h, "character.fauxBold");
|
||||
click(&mut h, bold.center(), Modifiers::NONE);
|
||||
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
assert!(doc.style_at(2).faux_bold, "selection is bold");
|
||||
assert!(!doc.style_at(12).faux_bold, "second paragraph untouched");
|
||||
assert_eq!(doc.runs().len(), 2);
|
||||
// Undo the style; Escape commits.
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer_doc(&h.state().session, LayerId(lid)).unwrap().is_uniform());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.execute("text.setSelection", json!({"start": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().session.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_enters_editing_and_selects_words() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let lid =
|
||||
s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
// A point inside "beta" (comp space → window).
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
let k = comp.width() / 640.0;
|
||||
let p = comp.min + egui::vec2(330.0 * k, 180.0 * k);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
let e = h.state().session.state.text_edit.clone().expect("double-click edits text");
|
||||
assert_eq!(e.layer.0, lid);
|
||||
assert_eq!((e.anchor.min(e.caret), e.anchor.max(e.caret)), (0, 10), "everything selected");
|
||||
// Double-clicking inside the edited text selects a word.
|
||||
h.run_steps(40);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
let e = h.state().session.state.text_edit.clone().unwrap();
|
||||
assert_eq!((e.anchor, e.caret), (6, 10));
|
||||
// Typing replaces it; Cmd+Z undoes while editing.
|
||||
type_text(&mut h, "gamma");
|
||||
assert_eq!(edited(&h).1, "alpha gamma");
|
||||
key(&mut h, Key::Z, Modifiers::COMMAND);
|
||||
assert_eq!(edited(&h).1, "alpha beta");
|
||||
// Paragraph panel: alignment applies to the edited paragraph.
|
||||
h.state_mut().show_panel(PanelKind::Paragraph);
|
||||
h.run_steps(3);
|
||||
let r = rect(&h, "paragraph.right");
|
||||
click(&mut h, r.center(), Modifiers::NONE);
|
||||
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
assert_eq!(doc.justify, effectcraft_engine::keyframe::Justify::Right);
|
||||
for id in
|
||||
["paragraph.indentLeft", "paragraph.spaceBefore", "paragraph.direction", "paragraph.composer", "paragraph.hangingPunctuation", "paragraph.justifyAll"]
|
||||
{
|
||||
assert!(h.state().auto.find(id).is_some(), "missing {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_drag_makes_paragraph_text_with_box_handles() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
|
||||
h.run_steps(1);
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
let a = comp.min + egui::vec2(comp.width() * 0.2, comp.height() * 0.2);
|
||||
let b = comp.min + egui::vec2(comp.width() * 0.7, comp.height() * 0.6);
|
||||
h.input_mut().events.push(Event::PointerMoved(a));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let (lid, _, _, _) = edited(&h);
|
||||
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
let bs = doc.box_size.expect("paragraph text");
|
||||
assert!((bs[0] - 320.0).abs() < 4.0 && (bs[1] - 144.0).abs() < 4.0, "{bs:?}");
|
||||
type_text(&mut h, "Some words that wrap inside the box when there are enough of them");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("viewer.textBox.handle.4").is_some());
|
||||
// Dragging the bottom-right handle widens the box.
|
||||
let hr = rect(&h, "viewer.textBox.handle.4").center();
|
||||
let to = hr + egui::vec2(60.0, 0.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(hr));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: hr, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(hr + (to - hr) * (i as f32 / 6.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let doc2 = layer_doc(&h.state().session, LayerId(lid)).unwrap();
|
||||
assert!(doc2.box_size.unwrap()[0] > bs[0] + 20.0, "{:?}", doc2.box_size);
|
||||
assert!(h.state().session.state.text_edit.is_some(), "still editing after resizing");
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of
|
||||
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
|
||||
//! (egui_kittest, UI logic only).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::state::Tool;
|
||||
use egui::{Event, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
EffectcraftApp::new(s)
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Comp pixel → screen point.
|
||||
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
|
||||
let c = rect(h, "viewer.comp");
|
||||
let z = c.width() / 640.0;
|
||||
c.min + vec2(p[0] * z, p[1] * z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_bar_in_after_effects_order() {
|
||||
let h = harness();
|
||||
let order = [
|
||||
"viewer.magnification",
|
||||
"viewer.resolution",
|
||||
"viewer.transparency",
|
||||
"viewer.masks",
|
||||
"viewer.roi",
|
||||
"viewer.grid",
|
||||
"viewer.channel",
|
||||
"viewer.resetExposure",
|
||||
"viewer.exposure",
|
||||
"viewer.snapshot",
|
||||
"viewer.showSnapshot",
|
||||
"viewer.fastPreviews",
|
||||
"viewer.timecode",
|
||||
];
|
||||
let xs: Vec<f32> = order.iter().map(|id| rect(&h, id).min.x).collect();
|
||||
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
|
||||
let mag = &h.state().auto.find("viewer.magnification").unwrap().label;
|
||||
assert_eq!(mag, "Magnification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_of_interest_is_drawn_in_the_viewer() {
|
||||
let mut h = harness();
|
||||
let at = rect(&h, "viewer.roi").center();
|
||||
click(&mut h, at);
|
||||
assert!(h.state().ui.viewer.roi_draw);
|
||||
let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0]));
|
||||
drag(&mut h, a, b);
|
||||
let r = h.state().session.state.region_of_interest.expect("roi");
|
||||
assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}");
|
||||
assert!(!h.state().ui.viewer.roi_draw);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("viewer.regionOfInterest").is_some());
|
||||
// The button clears it again.
|
||||
let at = rect(&h, "viewer.roi").center();
|
||||
click(&mut h, at);
|
||||
assert!(h.state().session.state.region_of_interest.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rulers_make_guides_and_guides_move() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.viewer.rulers = true;
|
||||
h.run_steps(3);
|
||||
let top = rect(&h, "viewer.ruler.top");
|
||||
let to = screen(&h, [0.0, 120.0]);
|
||||
drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y));
|
||||
let g = h.state().session.active_comp().unwrap().guides.clone();
|
||||
assert_eq!(g.len(), 1);
|
||||
assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}");
|
||||
// Drag the guide down with the Selection tool.
|
||||
let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y);
|
||||
let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y);
|
||||
drag(&mut h, from, to2);
|
||||
let g = h.state().session.active_comp().unwrap().guides.clone();
|
||||
assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
|
||||
let mut h = harness();
|
||||
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
// Grab the box at its centre and drop it 3 px from the comp centre: it snaps there.
|
||||
let from = screen(&h, [100.0, 100.0]);
|
||||
let to = screen(&h, [323.0, 182.0]);
|
||||
drag(&mut h, from, to);
|
||||
let pos = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
|
||||
l.props.prop("transform/position").unwrap().value.as_vec3()
|
||||
};
|
||||
let p = pos(&h);
|
||||
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}");
|
||||
// Snapping off: the same drag lands where the pointer is.
|
||||
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
drag(&mut h, from, to);
|
||||
let p = pos(&h);
|
||||
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pen_draws_a_closed_shape_layer() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
h.run_steps(2);
|
||||
let n0 = h.state().session.active_comp().unwrap().layers.len();
|
||||
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] {
|
||||
let at = screen(&h, p);
|
||||
click(&mut h, at);
|
||||
}
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
assert_eq!(comp.layers.len(), n0 + 1);
|
||||
let l = &comp.layers[0];
|
||||
assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape));
|
||||
let g = l.props.sub("contents").unwrap().groups().next().unwrap();
|
||||
assert_eq!(g.name, "Shape 1");
|
||||
let path = g.sub("contents").unwrap().groups().next().unwrap();
|
||||
let sp = path.get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert_eq!(sp.vertices.len(), 3);
|
||||
assert!(sp.closed);
|
||||
assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices);
|
||||
// G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen.
|
||||
let ctx = h.ctx.clone();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap();
|
||||
seen.push(h.state().ui.tool);
|
||||
}
|
||||
assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]);
|
||||
// Add Vertex on the first segment.
|
||||
h.state_mut().ui.tool = Tool::PenAdd;
|
||||
h.run_steps(2);
|
||||
let at = screen(&h, [300.0, 110.0]);
|
||||
click(&mut h, at);
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
let sp = comp.layers[0]
|
||||
.props
|
||||
.sub("contents")
|
||||
.unwrap()
|
||||
.groups()
|
||||
.next()
|
||||
.unwrap()
|
||||
.sub("contents")
|
||||
.unwrap()
|
||||
.groups()
|
||||
.next()
|
||||
.unwrap()
|
||||
.get("path")
|
||||
.unwrap()
|
||||
.value
|
||||
.as_path()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(sp.vertices.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_key_drag_edits_that_key() {
|
||||
let mut h = harness();
|
||||
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap();
|
||||
s.execute("view.snapping", json!({"value": false})).unwrap();
|
||||
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some());
|
||||
let from = screen(&h, [500.0, 100.0]);
|
||||
let to2 = screen(&h, [500.0, 300.0]);
|
||||
drag(&mut h, from, to2);
|
||||
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
|
||||
let k = &l.props.prop("transform/position").unwrap().keys;
|
||||
let v1 = k[1].value.as_vec3();
|
||||
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
|
||||
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_transform_box_scales_selected_keys_in_time() {
|
||||
let mut h = harness();
|
||||
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap();
|
||||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] {
|
||||
assert!(h.state().auto.find(id).is_some(), "missing {id}");
|
||||
}
|
||||
// Drag the right edge handle to the right: the keys spread out in time, the first stays.
|
||||
let r = rect(&h, "timeline.graph.transformBox.5");
|
||||
let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center();
|
||||
let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center();
|
||||
let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0);
|
||||
drag(&mut h, r.center(), to);
|
||||
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
|
||||
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(ts[0], 0.0);
|
||||
assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}");
|
||||
assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![0.0, 50.0, 100.0]);
|
||||
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
|
||||
assert_eq!(undo, 1, "one undo step per drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_alt_drag_scales_a_key_group_in_time() {
|
||||
let mut h = harness();
|
||||
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
let mut ks: Vec<Pos2> =
|
||||
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
|
||||
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
assert_eq!(ks.len(), 3, "{ks:?}");
|
||||
let (k0, k2) = (ks[0], ks[2]);
|
||||
// Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key.
|
||||
let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y);
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
h.input_mut().events.push(Event::PointerMoved(k2));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(alt));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt });
|
||||
h.run_steps(2);
|
||||
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
|
||||
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(ts.len(), 3);
|
||||
assert_eq!(ts[0], 0.0);
|
||||
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
|
||||
}
|
||||
|
|
@ -100,6 +100,29 @@ effectcraft-cli run clip.ecproj track.motion '{"layer":"#2"}' \
|
|||
track.analyze '{"wait":true}' track.apply '{}' --save
|
||||
```
|
||||
|
||||
### Text: styles and editing
|
||||
|
||||
Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the
|
||||
whole layer, or only characters `range: [start, end]` (character indices): character attributes
|
||||
(`font`, `size`, `fill`, `tracking`, `kerning: metrics|optical|<1/1000 em>`, `tsume`,
|
||||
`baseline: superscript|subscript`, `allCaps`…) split the text into runs; paragraph attributes
|
||||
(`justify`, `indentLeft`, `indentFirst`, `spaceBefore`, `direction`, `composer`,
|
||||
`hangingPunctuation`…) apply to the paragraphs the range touches. Editing mirrors the Type tool:
|
||||
|
||||
1. `layer.newText {"text":"", "box":[100,100,600,300], "edit":true}` makes paragraph text and
|
||||
starts editing (`position` instead of `box` makes point text; `vertical: true` vertical type).
|
||||
2. `text.insert {"text":"Hello world"}` types at the caret (replacing the selection);
|
||||
`text.setSelection {"start":6,"end":11}` selects "world"; `text.moveCaret {"to":"wordLeft",
|
||||
"extend":true}` moves like the arrow keys; `text.delete {"word":true}` is Alt+Backspace.
|
||||
3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the
|
||||
Character panel does the same); with an empty range it sets the style the next typed text takes.
|
||||
4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly`
|
||||
work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed).
|
||||
|
||||
In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters
|
||||
(`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a
|
||||
styled document.
|
||||
|
||||
## CLI
|
||||
|
||||
Each invocation runs a headless engine with no window. It opens the demo project unless you pass
|
||||
|
|
|
|||
|
|
@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.
|
|||
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
|
||||
`effects.item.<name>`. Use `ui.elements` to see what is on screen.
|
||||
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
|
||||
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,
|
||||
`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.<menu>Item.<n>`),
|
||||
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
|
||||
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.freeTransform.handle.<i>` and
|
||||
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
|
||||
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
|
||||
|
||||
Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`,
|
||||
`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`,
|
||||
`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`,
|
||||
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),
|
||||
`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and
|
||||
shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph
|
||||
Editor transform box, timeline Alt-drag scaling).
|
||||
|
||||
The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol;
|
||||
see [agents.md](agents.md).
|
||||
|
|
|
|||
|
|
@ -63,17 +63,17 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
|
|||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
|
||||
| Automation | 76% | 3.3 | a JavaScript scripting object model |
|
||||
| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes |
|
||||
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
|
||||
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
|
||||
| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch |
|
||||
| Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents |
|
||||
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
|
||||
| Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
|
||||
| Web | 60% | 2.5 | threads, browser storage, audio |
|
||||
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
|
||||
| Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more 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 | 55% | 6.1 | GPU 3D and adjustment layers, disk cache, region of interest, snapshots and exposure |
|
||||
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points |
|
||||
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking |
|
||||
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
|
||||
|
||||
|
|
@ -87,19 +87,19 @@ 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~~ (done: 2D compositing and 16 GPU effects; 3D runs
|
||||
3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13)
|
||||
4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; 3D runs
|
||||
and adjustment layers still composite on the CPU).
|
||||
5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier.
|
||||
6. Motion-path handles in the viewer; graph editor transform box and snapping.
|
||||
5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer.
|
||||
6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8)
|
||||
7. Point tracking and stabilization (in progress).
|
||||
8. Frame blending, collapse transformations, slip edit.
|
||||
9. A real 8/16/32-bit pipeline with linear blending and colour management.
|
||||
10. Drag-to-dock and floating panels, saved workspaces, native macOS menus.
|
||||
11. Auto-save, crash recovery, recent projects (in progress).
|
||||
12. Expression gaps: `sampleImage`, the `sourceText` style API, `footage()`.
|
||||
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
|
||||
13. Lottie (in progress), WebM, SVG and PSD import.
|
||||
14. Puppet and paint tools (in progress).
|
||||
15. Preferences and a shortcut editor that can rebind (in progress); real Wiggler, Smoother and Motion Sketch; the marker dialog.
|
||||
|
|
|
|||
Loading…
Reference in New Issue