M9.10: on-canvas text editing

Type tool point/box text creation, double-click editing, caret and selection
in the viewer (viewer_text.rs), keyboard/IME/clipboard, box handles; engine
text.edit/endEdit/setSelection/moveCaret/insert/delete, layer.setText range,
caret insertion style, Paste Text and Match Formatting / Paste Text
Formatting Only (stubs removed); Character/Paragraph/Properties panels act
on the selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 10:34:13 -04:00
co-authored by Claude Opus 5.5
parent f78435927b
commit 744ccd6b48
16 changed files with 1890 additions and 225 deletions
Generated
+1
View File
@@ -1245,6 +1245,7 @@ dependencies = [
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
"serde_json",
]
[[package]]
+19
View File
@@ -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);
}
+89 -83
View File
@@ -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}))
}
@@ -801,20 +779,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();
}
@@ -922,7 +920,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!(
@@ -996,7 +1002,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
),
+2
View File
@@ -27,6 +27,7 @@ mod settings;
mod stubs;
mod styles;
mod text_anim;
pub mod text_edit;
mod three_d;
mod time;
mod track;
@@ -92,6 +93,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());
v.extend(prop::specs());
v.extend(anim::specs());
-3
View File
@@ -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"], "{}"),
+540
View File
@@ -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)
}
+10
View File
@@ -36,6 +36,7 @@ pub use effectcraft_geom as geom;
pub use effectcraft_keyframe as keyframe;
pub use effectcraft_project as project;
pub use effectcraft_render as render;
pub use effectcraft_text as text;
pub use effectcraft_time as time;
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
@@ -178,6 +179,12 @@ pub struct EditorState {
/// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines).
#[serde(default)]
pub hide_locked_masks: bool,
/// On-canvas text editing (Type tool): the edited layer, selection and insertion style.
#[serde(default)]
pub text_edit: Option<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>,
}
fn one_view() -> u8 {
@@ -394,6 +401,7 @@ impl Session {
self.state.current_track = None;
}
self.state.project_selection.retain(|i| p.item(*i).is_some());
commands::text_edit::sanitize(self);
}
pub fn is_dirty(&self) -> bool {
@@ -532,6 +540,8 @@ mod tests_styles;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
mod tests_text_edit;
#[cfg(test)]
mod tests_timeline;
#[cfg(test)]
mod tests_track;
+204
View File
@@ -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);
}
+1
View File
@@ -23,6 +23,7 @@ pub mod text_panels;
pub mod timeline;
pub mod tracker;
pub mod viewer;
pub mod viewer_text;
pub mod waveform;
use effectcraft_engine::Session;
+8 -1
View File
@@ -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;
}
+266 -85
View File
@@ -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).
+10 -50
View File
@@ -733,7 +733,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);
@@ -1014,6 +1017,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
});
}
if resp.clicked()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
@@ -1026,12 +1030,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.viewer.zoom = Some(nz);
app.ui.viewer.pan = [pan.x, pan.y];
}
Tool::Type | Tool::TypeVertical => {
let r = app.session.execute("layer.newText", json!({"text": "Text", "size": 96, "position": [cpt[0], cpt[1]], "justify": "left"}));
if let Ok(v) = r {
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string())));
}
}
Tool::Type | Tool::TypeVertical => {}
Tool::Pen => {}
t if t.puppet_kind().is_some() => {
if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
@@ -1059,22 +1058,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
if resp.double_clicked()
&& !text_owns
&& app.ui.tool != Tool::Pen
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
match &l.source {
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
effectcraft_engine::project::LayerSource::Text => {
let doc = effectcraft_engine::render::text::source_text(&ectx, l).map(|d| d.text).unwrap_or_default();
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (l.id.0, doc)));
}
_ => {}
}
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
{
app.session.open_comp(*item);
}
}
text_edit_overlay(app, ui, &map);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
@@ -1299,40 +1293,6 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
);
}
/// Inline text editing for a text layer (double-click or Type tool click).
fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap) {
let id = egui::Id::new("viewer-text-edit");
let Some((lid, mut text)) = ui.data(|d| d.get_temp::<(u64, String)>(id)) else { return };
let comp = app.session.active_comp().cloned();
let Some(comp) = comp else { return };
let Some(layer) = comp.layer(LayerId(lid)).cloned() else {
ui.data_mut(|d| d.remove::<(u64, String)>(id));
return;
};
let pos = layer.transform().and_then(|tr| tr.get("position")).map(|p| p.value_at(layer.layer_time(app.session.time())).as_vec2()).unwrap_or([0.0, 0.0]);
let at = map.to_screen(pos) + vec2(-20.0, 10.0);
let mut done = false;
egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(at).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new("Edit text — Enter to commit, Esc to cancel").small());
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_width(320.0).desired_rows(2).font(Tokens::ui(16.0)));
r.request_focus();
if ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift) {
let _ = app.session.execute("layer.setText", json!({"layer": lid, "text": text.trim_end_matches('\n')}));
done = true;
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
done = true;
}
});
});
if done {
ui.data_mut(|d| d.remove::<(u64, String)>(id));
} else {
ui.data_mut(|d| d.insert_temp(id, (lid, text)));
}
}
fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &effectcraft_engine::project::Comp) {
let t = app.tokens;
let p = ui.painter().clone();
+522
View File
@@ -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::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 });
});
}
+192
View File
@@ -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");
}
+23
View File
@@ -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
+3 -3
View File
@@ -66,7 +66,7 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch |
| Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents |
| Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
| Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects |
@@ -87,7 +87,7 @@ Mocha and Cineware are third-party and not counted.
## Highest-value gaps, in order
1. On-canvas text editing and per-character styles.
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.
3. Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.
4. A GPU (wgpu) compositor, then GPU effects.
@@ -98,7 +98,7 @@ Mocha and Cineware are third-party and not counted.
9. A real 8/16/32-bit pipeline with linear blending and colour management.
10. Drag-to-dock and floating panels, saved workspaces, native macOS menus.
11. Auto-save, crash recovery, recent projects (in progress).
12. Expression gaps: `sampleImage`, the `sourceText` style API, `footage()`.
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
13. Lottie (in progress), WebM, SVG and PSD import.
14. Puppet and paint tools (in progress).
15. Preferences and a shortcut editor that can rebind (in progress); real Wiggler, Smoother and Motion Sketch; the marker dialog.