Merge branch 'worktree-agent-a8807cba49d60c29f'

This commit is contained in:
Brandon Thomas 2026-10-02 10:47:14 -04:00
commit 81a8e717ea
27 changed files with 4169 additions and 478 deletions

1
Cargo.lock generated
View File

@ -1246,6 +1246,7 @@ dependencies = [
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
"serde_json",
]
[[package]]

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);
}

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}))
}
@ -852,20 +830,40 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
let t = s.time();
let range = text_range_p(p);
// Character attributes with only a caret (no selected text) set the insertion style.
if let Some(r) = range.clone().filter(|r| r.is_empty() && str_p(p, "text").is_none())
&& s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid)
&& let Some(doc) = super::text_edit::layer_doc(s, lid)
&& doc.char_len() > 0
&& let Some(obj) = p.as_object()
{
let e = s.state.text_edit.as_mut().expect("checked");
let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start));
let mut any = false;
for (k, v) in obj {
any |= effectcraft_keyframe::text_doc::apply_char_attr(&mut st, k, v).map_err(|m| bad("layer.setText", m))?;
}
if any {
e.pending = Some(st);
}
s.bump();
}
s.edit("Edit Text", merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?;
let cur = match pr.value_at(lt) {
let mut doc = match pr.value_at(lt) {
KV::Text(d) => *d,
KV::Str(t) => TextDoc::plain(&t),
_ => TextDoc::default(),
};
let doc = text_doc_from(p, cur);
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
if let Some(first) = doc.text.lines().next()
&& str_p(p, "text").is_some()
&& !first.is_empty()
&& l.name.starts_with("Text")
&& (l.name.starts_with("Text") || l.name.is_empty())
{
l.name = first.chars().take(40).collect();
}
@ -973,7 +971,15 @@ fn with_layer(p: &Value, lid: LayerId) -> Value {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text),
cmd!(
"layer.newText",
"Text",
["Layer", "New"],
Some("Cmd+Alt+Shift+T"),
"{text?, position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
has_comp,
new_text
),
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
cmd!(
@ -1058,7 +1064,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, smallCaps?, fauxBold?, fauxItalic?, hScale? %, vScale? %, baselineShift? px, strokeOverFill?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),

View File

@ -28,6 +28,7 @@ mod settings;
mod stubs;
mod styles;
mod text_anim;
pub mod text_edit;
mod three_d;
mod time;
mod track;
@ -96,6 +97,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());
v.extend(prop::specs());
v.extend(anim::specs());

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"], "{}"),

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)
}

View File

@ -37,6 +37,7 @@ pub use effectcraft_geom as geom;
pub use effectcraft_keyframe as keyframe;
pub use effectcraft_project as project;
pub use effectcraft_render as render;
pub use effectcraft_text as text;
pub use effectcraft_time as time;
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
@ -179,6 +180,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>,
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
#[serde(default)]
pub viewer: commands::viewer_cmds::ViewOptions,
@ -401,6 +408,7 @@ impl Session {
self.state.current_track = None;
}
self.state.project_selection.retain(|i| p.item(*i).is_some());
commands::text_edit::sanitize(self);
}
pub fn is_dirty(&self) -> bool {
@ -546,6 +554,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;

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);
}

View File

@ -13,6 +13,7 @@ workspace = true
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-keyframe = { workspace = true }
serde_json = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-render = { workspace = true }

View File

@ -49,6 +49,17 @@ pub enum Req {
SourceRect { comp: u64, layer: u64, t: Secs, extents: bool },
/// `[[time, duration, comment, chapter, url], …]` for a layer, or the comp when `layer` is None.
Markers { comp: u64, layer: Option<u64> },
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
/// `layer.setText` keys), or null for other properties.
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
}
/// A text document as the style API's JSON.
pub fn doc_json(d: &effectcraft_keyframe::TextDoc) -> String {
use effectcraft_keyframe::text_doc::{char_style_json, para_style_json};
let runs: Vec<serde_json::Value> = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect();
let paras: Vec<serde_json::Value> = d.paras().iter().map(para_style_json).collect();
serde_json::json!({"doc": d, "text": d.text, "runs": runs, "paras": paras}).to_string()
}
/// Plain data answer, converted to a JS value by the runtime.
@ -292,6 +303,16 @@ impl<'a> Resolver<'a> {
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
value_resp(&v, prop_dims(l, p))
}
Req::Doc { comp, layer, path, t, pre } => {
let (l, p) = self.prop(*comp, *layer, path)?;
let t = f64::from_bits(*t);
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
match v {
Value::Text(d) => Resp::Str(doc_json(&d)),
Value::Str(s) => Resp::Str(doc_json(&effectcraft_keyframe::TextDoc::plain(&s))),
_ => Resp::Null,
}
}
Req::Xform { comp, layer, t } => {
let l = self.layer(*comp, *layer)?;
let ctx = self.ctx_at(*comp, f64::from_bits(*t))?;

View File

@ -17,7 +17,7 @@ var textTotal = 1;
var selectorValue = [100, 100, 100];
// Request ops (must match runtime.rs).
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9;
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
@ -34,6 +34,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
function __finish(r) {
r = __v(r);
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
// host requests can still be answered and the script re-run.
if (Array.isArray(r)) return r.map(function (x) { return __v(x); });
@ -640,6 +641,18 @@ Prop.prototype = {
inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); },
outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); },
isClosed: function () { return this.value.isClosed(); },
// Source Text style API.
__docAt: function (t) {
var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self);
if (d === null) __err('Property "' + this.name + '" is not a Source Text property');
return JSON.parse(d);
},
get style() { return this.getStyleAt(0); },
getStyleAt: function (i, t) {
if (t === undefined) t = time;
return new __TextStyle(this.__docAt(__v(t)), i === undefined ? 0 : Math.floor(__v(i)), [], false);
},
createStyle: function () { return new __TextStyle(this.__docAt(time), 0, [], true); },
valueOf: function () { return this.value; },
toString: function () { return String(this.value); },
};
@ -658,6 +671,138 @@ function __mkProp(c, l, path) {
return new Proxy(new Prop(c, l, path), __propHandler);
}
// Text styles (the Source Text style API) ------------------------------------------------------
// A TextStyle is the source document (as JSON from the host), the character index it reads its
// attributes at, and the setter calls made on it ([key, value, start, count]). Setters return
// new objects; returning a style from a Source Text expression applies it.
function __TextStyle(doc, idx, ops, empty) { this.__d = doc; this.__i = idx; this.__ops = ops; this.__empty = empty; }
function __styleSetter(key, conv) {
return function (v, start, count) {
v = __v(v);
if (conv) v = conv(v, this);
var s = start === undefined ? -1 : Math.max(0, Math.floor(__v(start)));
var n = count === undefined ? -1 : Math.max(0, Math.floor(__v(count)));
return new __TextStyle(this.__d, this.__i, this.__ops.concat([[key, v, s, n]]), this.__empty);
};
}
function __colorIn(c) {
if (typeof c === 'string') return c;
return [c[0], c[1], c[2]];
}
__TextStyle.prototype = {
__isTextStyle: true,
__base: function () {
var runs = this.__d.runs, pos = 0, i = this.__i;
for (var k = 0; k < runs.length; k++) {
if (i < pos + runs[k].len || k === runs.length - 1) return runs[k].style;
pos += runs[k].len;
}
return {};
},
__para: function () {
var t = this.text, p = 0;
for (var k = 0; k < Math.min(this.__i, t.length); k++) if (t[k] === '\n' || t[k] === '\r') p++;
var paras = this.__d.paras;
return paras[Math.min(p, paras.length - 1)];
},
__get: function (key, para) {
for (var k = this.__ops.length - 1; k >= 0; k--) {
var o = this.__ops[k];
if (o[0] !== key) continue;
if (o[2] < 0 || (this.__i >= o[2] && (o[3] < 0 || this.__i < o[2] + o[3]))) return o[1];
}
return (para ? this.__para() : this.__base())[key];
},
get text() {
var t = this.__d.text;
for (var k = 0; k < this.__ops.length; k++) {
var o = this.__ops[k];
if (o[0] !== 'text') continue;
if (o[2] < 0) t = String(o[1]);
else t = t.slice(0, o[2]) + String(o[1]) + (o[3] < 0 ? '' : t.slice(o[2] + o[3]));
}
return t;
},
get font() { return this.__get('font'); },
get fontSize() { return this.__get('size'); },
get isFauxBold() { return this.__get('fauxBold'); },
get isFauxItalic() { return this.__get('fauxItalic'); },
get isAllCaps() { return this.__get('allCaps'); },
get isSmallCaps() { return this.__get('smallCaps'); },
get tracking() { return this.__get('tracking'); },
get autoLeading() { return this.__get('leading') === 'auto'; },
get leading() { var l = this.__get('leading'); return l === 'auto' ? this.fontSize * 1.2 : l; },
get baselineShift() { return this.__get('baselineShift'); },
get applyFill() { return this.__get('applyFill'); },
get fillColor() { var c = this.__get('fill'); return [c[0], c[1], c[2]]; },
get applyStroke() { return this.__get('applyStroke'); },
get strokeColor() { var c = this.__get('stroke'); return [c[0], c[1], c[2]]; },
get strokeWidth() { return this.__get('strokeWidth'); },
get horizontalScaling() { return this.__get('hScale'); },
get verticalScaling() { return this.__get('vScale'); },
get tsume() { return this.__get('tsume'); },
get baselineOption() { return this.__get('baseline'); },
get isSuperscript() { return this.__get('baseline') === 'superscript'; },
get isSubscript() { return this.__get('baseline') === 'subscript'; },
get kerningType() { var k = this.__get('kerning'); return typeof k === 'number' ? 'manual' : k; },
get kerning() { var k = this.__get('kerning'); return typeof k === 'number' ? k : 0; },
get isLigature() { return this.__get('ligatures'); },
get justification() { return this.__get('justify', true); },
get firstLineIndent() { return this.__get('indentFirst', true); },
get startIndent() { return this.__get('indentLeft', true); },
get endIndent() { return this.__get('indentRight', true); },
get spaceBefore() { return this.__get('spaceBefore', true); },
get spaceAfter() { return this.__get('spaceAfter', true); },
get direction() { return this.__get('direction', true); },
get isEveryLineComposer() { return this.__get('composer', true) === 'everyLine'; },
get isHangingRoman() { return this.__get('hangingPunctuation', true); },
setText: __styleSetter('text', function (v) { return String(v); }),
replaceText: __styleSetter('text', function (v) { return String(v); }),
setFont: __styleSetter('font', function (v) { return String(v); }),
setFontSize: __styleSetter('size'),
setFauxBold: __styleSetter('fauxBold', Boolean),
setFauxItalic: __styleSetter('fauxItalic', Boolean),
setAllCaps: __styleSetter('allCaps', Boolean),
setSmallCaps: __styleSetter('smallCaps', Boolean),
setTracking: __styleSetter('tracking'),
setLeading: __styleSetter('leading'),
setAutoLeading: __styleSetter('leading', function (v, s) { return v ? 'auto' : s.leading; }),
setBaselineShift: __styleSetter('baselineShift'),
setApplyFill: __styleSetter('applyFill', Boolean),
setFillColor: __styleSetter('fill', __colorIn),
setApplyStroke: __styleSetter('applyStroke', Boolean),
setStrokeColor: __styleSetter('stroke', __colorIn),
setStrokeWidth: __styleSetter('strokeWidth'),
setHorizontalScaling: __styleSetter('hScale'),
setVerticalScaling: __styleSetter('vScale'),
setTsume: __styleSetter('tsume'),
setBaselineOption: __styleSetter('baseline', function (v) { return String(v).toLowerCase().replace('_baseline', ''); }),
setSuperscript: __styleSetter('superscript', Boolean),
setSubscript: __styleSetter('subscript', Boolean),
setKerningType: __styleSetter('kerning', function (v) { v = String(v).toLowerCase(); return v === 'manual' ? 0 : v; }),
setKerning: __styleSetter('kerning', Number),
setLigature: __styleSetter('ligatures', Boolean),
setJustification: __styleSetter('justify', function (v) { return String(v); }),
setFirstLineIndent: __styleSetter('indentFirst'),
setStartIndent: __styleSetter('indentLeft'),
setEndIndent: __styleSetter('indentRight'),
setSpaceBefore: __styleSetter('spaceBefore'),
setSpaceAfter: __styleSetter('spaceAfter'),
setDirection: __styleSetter('direction', function (v) { v = String(v).toLowerCase(); return v.indexOf('right') === 0 || v === 'rtl' ? 'rtl' : 'ltr'; }),
setEveryLineComposer: __styleSetter('composer', function (v) { return v ? 'everyLine' : 'singleLine'; }),
setHangingRoman: __styleSetter('hangingPunctuation', Boolean),
__out: function () {
return { __style: true, __json: JSON.stringify({ doc: this.__empty ? null : this.__d.doc, ops: this.__ops }) };
},
toString: function () { return this.text; },
valueOf: function () { return this.text; },
};
Object.defineProperty(__TextStyle.prototype, 'length', { get: function () { return this.text.length; } });
// Markers ---------------------------------------------------------------------------------------
function Markers(c, l) { this.__c = c; this.__l = l; this.__m = null; }

View File

@ -70,7 +70,14 @@ pub enum Out {
Num(f64),
Str(String),
Arr(Vec<Out>),
Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool },
Path {
points: Vec<[f64; 2]>,
ins: Vec<[f64; 2]>,
outs: Vec<[f64; 2]>,
closed: bool,
},
/// A text style object: JSON `{doc: TextDoc | null, ops: [[key, value, start, count], …]}`.
Style(String),
Object(String),
}
@ -131,6 +138,13 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
let l = num(args, 2, ctx)?;
Req::Markers { comp: id(args, 1, ctx)?, layer: (l >= 0.0).then_some(l as u64) }
}
10 => Req::Doc {
comp: id(args, 1, ctx)?,
layer: id(args, 2, ctx)?,
path: string(args, 3, ctx)?,
t: secs(num(args, 4, ctx)?),
pre: arg(args, 5).to_boolean(),
},
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
};
let found = FRAMES.with(|f| {
@ -278,6 +292,10 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
}
return Ok(Out::Arr(out));
}
if o.get(js_string!("__style"), ctx).map_err(err)?.to_boolean() {
let j = o.get(js_string!("__json"), ctx).map_err(err)?;
return Ok(Out::Style(j.as_string().map(|s| s.to_std_string_escaped()).unwrap_or_default()));
}
if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() {
let pts = |name: JsString, ctx: &mut Context| -> Result<Vec<[f64; 2]>, String> {
let v = o.get(name, ctx).map_err(err)?;
@ -408,6 +426,9 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::<Vec<_>>().join(","),
Out::Undefined | Out::Null => String::new(),
Out::Path { .. } => "[object Path]".into(),
Out::Style(j) => {
serde_json::from_str::<serde_json::Value>(j).ok().map(|v| styled_doc(&v, &effectcraft_keyframe::TextDoc::default()).text).unwrap_or_default()
}
Out::Object(s) => s.clone(),
}
};
@ -428,11 +449,17 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
}
Value::Bool(_) => Value::Bool(truthy(out)),
Value::Enum(_) => Value::Enum((finite(numeric(out).unwrap_or(1.0))? - 1.0).round().max(0.0) as u32),
Value::Text(doc) => {
let mut d = doc.clone();
d.text = text(out);
Value::Text(d)
}
Value::Text(doc) => match out {
Out::Style(j) => {
let v: serde_json::Value = serde_json::from_str(j).map_err(|e| format!("Error: bad text style: {e}"))?;
Value::Text(Box::new(styled_doc(&v, doc)))
}
_ => {
let mut d = doc.clone();
d.set_text(&text(out));
Value::Text(d)
}
},
Value::Str(_) => Value::Str(text(out)),
Value::Path(_) => match out {
Out::Path { points, ins, outs, closed } => {
@ -450,6 +477,48 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
})
}
/// Apply a returned text style (`{doc, ops}`) to the property's own document `own`: the style's
/// source document (another layer's, or this one's) gives the formatting, then the setter calls
/// apply in order. Without `setText` the text stays `own`'s.
pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> effectcraft_keyframe::TextDoc {
use effectcraft_keyframe::TextDoc;
let mut d = own.clone();
if let Some(src) = v.get("doc").filter(|s| !s.is_null()).and_then(|s| serde_json::from_value::<TextDoc>(s.clone()).ok()) {
if src.text == own.text {
// The same text (this layer's own style, or an identical one): every run carries over.
d = TextDoc { box_size: own.box_size, box_pos: own.box_pos, vertical: own.vertical, ..src };
} else {
let first = src.style_at(0);
d.runs.clear();
d.apply_style_all(|s| *s = first.clone());
let p0 = src.para(0);
d.set_paras(vec![p0; d.para_count()]);
d.stroke_over_fill = src.stroke_over_fill;
}
}
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
let Some(key) = op.get(0).and_then(|k| k.as_str()) else { continue };
let val = op.get(1).cloned().unwrap_or_default();
let start = op.get(2).and_then(|x| x.as_i64()).unwrap_or(-1);
let count = op.get(3).and_then(|x| x.as_i64()).unwrap_or(-1);
let n = d.char_len();
let range = (start >= 0).then(|| {
let a = (start as usize).min(n);
a..if count < 0 { n } else { (a + count as usize).min(n) }
});
if key == "text" {
let s = val.as_str().unwrap_or_default();
match range {
Some(r) => d.replace_range(r, s, None),
None => d.set_text(s),
}
continue;
}
let _ = d.set_attr(key, &val, range);
}
d
}
/// Number → string like JS (`3`, not `3.0`).
fn js_number(x: f64) -> String {
if x.is_finite() && x == x.trunc() && x.abs() < 1e21 { format!("{}", x as i64) } else { format!("{x}") }

View File

@ -723,6 +723,60 @@ fn source_text_reading_and_string_methods() {
assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0);
}
#[test]
fn source_text_style_api() {
let mut f = fx();
let doc = |f: &Fx, e: &str| -> TextDoc {
match f.ev(f.title, "text/sourceText", e, 0.0) {
Ok(Value::Text(d)) => *d,
r => panic!("{e}: {r:?}"),
}
};
// Getters read the first character's style.
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fontSize", 0.0), 40.0);
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.isAllCaps ? 1 : 0", 0.0), 0.0);
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.autoLeading ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.leading", 0.0), 48.0);
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fillColor[1]", 0.0), 1.0);
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setFontSize(12).fontSize", 0.0), 12.0);
// Setters chain and return a styled document.
let d = doc(&f, "text.sourceText.style.setFontSize(80).setFillColor([1, 0, 0]).setAllCaps(true)");
assert_eq!(d.text, "Hello");
assert!(d.is_uniform());
assert_eq!((d.size, d.fill, d.all_caps), (80.0, [1.0, 0.0, 0.0, 1.0], true));
// Character ranges create runs; setText replaces the text.
let d = doc(&f, "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)");
assert_eq!(d.text, "Hi there");
let sizes: Vec<(usize, f64, bool)> = d.runs().iter().map(|r| (r.len, r.style.size, r.style.faux_bold)).collect();
assert_eq!(sizes, vec![(2, 40.0, true), (1, 40.0, false), (5, 20.0, false)]);
// getStyleAt reads a styled document per character (and at a time).
{
let l = f.layer_mut(f.title);
let p = l.props.prop_mut("text/sourceText").unwrap();
let Value::Text(d) = &mut p.value else { panic!() };
d.apply_style(1..3, |s| s.size = 99.0);
}
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(2, 0).fontSize", 0.0), 99.0);
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(4).fontSize", 0.0), 40.0);
// Returning the own style keeps its runs; plain strings keep the first character's style.
let d = doc(&f, "text.sourceText.style");
assert_eq!(d.runs().len(), 3);
let d = doc(&f, "'Bye'");
assert_eq!((d.text.as_str(), d.size), ("Bye", 40.0));
// Paragraph setters.
let d = doc(&f, "text.sourceText.style.setJustification('CENTER_JUSTIFY').setStartIndent(12).setSpaceBefore(4).setEveryLineComposer(false)");
assert_eq!(d.justify, effectcraft_keyframe::Justify::Center);
assert_eq!((d.indent_left, d.space_before), (12.0, 4.0));
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setStartIndent(7).startIndent", 0.0), 7.0);
// createStyle sets only what's given and keeps the existing runs.
let d = doc(&f, "thisComp.layer('Title').text.sourceText.createStyle().setTsume(50).setBaselineOption('superscript')");
assert_eq!(d.text, "Hello");
assert!(d.runs().iter().all(|r| r.style.tsume == 50.0 && r.style.baseline == effectcraft_keyframe::BaselineOption::Superscript));
assert_eq!(d.runs().len(), 3);
// Non-text properties have no style.
assert!(f.ev(f.a, ROT, "transform.rotation.style.fontSize", 0.0).is_err());
}
#[test]
fn numbers_to_source_text() {
let f = fx();

View File

@ -8,10 +8,12 @@
//! (pixels/second), evaluated through an arc-length table.
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
pub mod text_doc;
pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.

File diff suppressed because it is too large Load Diff

View File

@ -52,76 +52,7 @@ impl ShapePath {
}
}
/// Horizontal paragraph alignment.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
#[default]
Left,
Center,
Right,
JustifyLastLeft,
JustifyLastCenter,
JustifyLastRight,
JustifyAll,
}
/// The value of a text layer's Source Text (hold-interpolated).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextDoc {
pub text: String,
pub font: String,
pub style: String,
pub size: f64,
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub apply_fill: bool,
pub apply_stroke: bool,
pub stroke_over_fill: bool,
pub tracking: f64,
/// None = auto (120% of size).
pub leading: Option<f64>,
pub justify: Justify,
pub baseline_shift: f64,
pub h_scale: f64,
pub v_scale: f64,
pub faux_bold: bool,
pub faux_italic: bool,
pub all_caps: bool,
pub small_caps: bool,
/// Paragraph text box (width, height) with top-left at `box_pos`; None = point text.
pub box_size: Option<[f64; 2]>,
pub box_pos: [f64; 2],
}
impl Default for TextDoc {
fn default() -> Self {
TextDoc {
text: String::new(),
font: "Inter".into(),
style: "Regular".into(),
size: 72.0,
fill: [1.0, 1.0, 1.0, 1.0],
stroke: [0.0, 0.0, 0.0, 1.0],
stroke_width: 0.0,
apply_fill: true,
apply_stroke: false,
stroke_over_fill: true,
tracking: 0.0,
leading: None,
justify: Justify::Left,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
faux_bold: false,
faux_italic: false,
all_caps: false,
small_caps: false,
box_size: None,
box_pos: [0.0, 0.0],
}
}
}
pub use crate::text_doc::{Justify, TextDoc};
/// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -345,10 +276,13 @@ impl Value {
Value::Text(t) => {
if let Some(s) = j.as_str() {
let mut t = (**t).clone();
t.text = s.to_string();
t.set_text(s);
Some(Value::Text(Box::new(t)))
} else {
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|mut t| {
t.normalize();
Value::Text(Box::new(t))
})
}
}
Value::Layer(_) => Some(Value::Layer(j.as_u64())),

View File

@ -13,7 +13,7 @@ use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::Image;
use effectcraft_text::path_text::{self, PathMeasure};
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph, layout_doc};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
use crate::eval::EvalCtx;
@ -402,6 +402,8 @@ pub struct PlacedGlyph {
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
/// The character's style fills it (Character panel fill on).
pub apply_fill: bool,
}
impl PlacedGlyph {
@ -479,14 +481,14 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let mut subs: Vec<Option<char>> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect();
let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3);
let (lay, xfs) = if relayout {
let src: Vec<char> = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect();
let mut chars = src.clone();
let mut chars: Vec<char> = doc.text.chars().collect();
for (g, s) in lay.glyphs.iter().zip(&subs) {
if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) {
*slot = *c;
}
}
let d2 = TextDoc { text: chars.into_iter().collect(), all_caps: false, ..doc.clone() };
// Same character count: the style runs still line up.
let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() };
let l2 = layout_doc(&d2);
let by_char: std::collections::HashMap<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
let x2: Vec<CharXf> = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect();
@ -506,11 +508,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
while li_end < n && lay.glyphs[li_end].line_index == line {
li_end += 1;
}
let em = doc.size / 1000.0;
let mut pen = 0.0;
let (mut la, mut la_k) = (0.0, 0.0);
for gi in li_start..li_end {
let x = &xfs[gi];
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
pen += x.track_before * em;
shift[gi] = pen;
pen += x.track_after * em;
@ -581,7 +583,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
}
}
let base_stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
let base = doc.base_style();
let mut glyphs = Vec::with_capacity(n);
for gi in 0..n {
let g = &lay.glyphs[gi];
@ -597,10 +599,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
x.rotation_y = 0.0;
}
let o = origin(gi);
let st = lay.styles.get(g.run).unwrap_or(&base);
let size = st.size;
// Outline (substituted characters aligned in the original slot).
let (outline, adv) = match subs[gi] {
Some(c) => {
let (p, a) = char_glyph(&doc, c);
let (p, a) = char_glyph_style(st, c);
let dx = match x.char_align {
0 => 0.0,
2 => g.advance - a,
@ -614,8 +618,8 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
continue;
}
let pivot = match extents.get(&group_key(g)) {
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * doc.size],
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * doc.size],
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * size],
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * size],
};
// Outline relative to the pivot.
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
@ -635,9 +639,10 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
}
None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m,
};
let fill = adjust_color(doc.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
let stroke = adjust_color(doc.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0) });
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill });
}
Some(TextGeom { doc, glyphs, fill_stroke, blend })
}
@ -654,21 +659,21 @@ type Pass = (usize, bool);
fn passes(geom: &TextGeom) -> Vec<Pass> {
let doc = &geom.doc;
let n = geom.glyphs.len();
let fill_on = doc.apply_fill;
let fill_on = |i: usize| geom.glyphs[i].apply_fill;
let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0;
let mut v = Vec::with_capacity(n * 2);
match geom.fill_stroke {
1 => {
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
}
2 => {
v.extend((0..n).filter(|_| fill_on).map(|i| (i, false)));
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
}
_ => {
for i in 0..n {
let f = fill_on.then_some((i, false));
let f = fill_on(i).then_some((i, false));
let s = has_stroke(i).then_some((i, true));
if doc.stroke_over_fill {
v.extend(f);

View File

@ -1,6 +1,9 @@
//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,
//! tracking, baseline shift, all caps / small caps, underline. Results are cached.
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14, greedy or every-line),
//! alignment and justification, indents, paragraph spacing, leading, tracking, tsume, manual and
//! optical kerning, baseline shift, superscript / subscript, horizontal / vertical scale, all caps
//! / small caps, hanging punctuation, underline. Text may mix character styles (runs) and
//! paragraph settings. Results are cached.
//!
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
@ -34,6 +37,19 @@ pub enum Align {
Justify,
}
/// Superscript / subscript position.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Script {
#[default]
Normal,
Super,
Sub,
}
/// Superscript / subscript size and baseline offset (fractions of the font size).
pub const SCRIPT_SIZE: f32 = 0.583;
pub const SCRIPT_OFFSET: f32 = 0.333;
/// Character formatting.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
@ -45,6 +61,10 @@ pub struct TextStyle {
pub tracking: f32,
/// Metric kerning (`kern`).
pub kerning: bool,
/// Optical kerning (spacing from the outlines; replaces metric kerning).
pub optical: bool,
/// Manual kerning before every character of the run, in 1/1000 em (replaces metrics).
pub manual_kern: Option<f32>,
/// Standard ligatures (`liga`, `clig`).
pub ligatures: bool,
/// Baseline shift in pixels (positive = up).
@ -53,6 +73,14 @@ pub struct TextStyle {
pub faux_italic: bool,
pub caps: Caps,
pub underline: bool,
/// Horizontal / vertical scale (1 = 100%).
pub h_scale: f32,
pub v_scale: f32,
/// Tsume: fraction (0–1) of the space around each glyph removed.
pub tsume: f32,
pub script: Script,
/// Baseline-to-baseline distance in pixels; None = auto (120% of the size).
pub leading: Option<f32>,
}
impl Default for TextStyle {
@ -63,26 +91,87 @@ impl Default for TextStyle {
size: 100.0,
tracking: 0.0,
kerning: true,
optical: false,
manual_kern: None,
ligatures: true,
baseline_shift: 0.0,
faux_bold: false,
faux_italic: false,
caps: Caps::Normal,
underline: false,
h_scale: 1.0,
v_scale: 1.0,
tsume: 0.0,
script: Script::Normal,
leading: None,
}
}
}
impl TextStyle {
/// The size glyphs are drawn at (superscript / subscript are smaller).
pub fn glyph_size(&self) -> f32 {
match self.script {
Script::Normal => self.size,
_ => self.size * SCRIPT_SIZE,
}
}
/// Upward baseline offset: baseline shift plus the superscript / subscript offset.
pub fn rise(&self) -> f32 {
self.baseline_shift
+ match self.script {
Script::Normal => 0.0,
Script::Super => self.size * SCRIPT_OFFSET,
Script::Sub => -self.size * SCRIPT_OFFSET,
}
}
/// The line advance this style asks for.
pub fn line_advance(&self) -> f32 {
self.leading.unwrap_or(self.size * 1.2)
}
}
/// Paragraph formatting.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct ParagraphStyle {
pub align: Align,
/// Extra line spacing in pixels added to the font's natural line height (Leading).
/// How the last line of a justified paragraph is aligned (`Justify` = justify it too).
pub justify_last: Align,
/// Extra line spacing in pixels added to every line advance.
pub leading: f32,
/// Wrap width (area text); None = point text.
/// Wrap width (area text); None = point text. Taken from the first paragraph.
pub width: Option<f32>,
/// Base direction: None = from the first strong character.
pub rtl: Option<bool>,
/// Start / end margins (left / right in left-to-right paragraphs) and the first line's indent.
pub indent_start: f32,
pub indent_end: f32,
pub indent_first: f32,
pub space_before: f32,
pub space_after: f32,
/// Every-line composer (balanced breaks) instead of single-line (greedy).
pub every_line: bool,
/// Roman hanging punctuation.
pub hanging: bool,
}
impl Default for ParagraphStyle {
fn default() -> Self {
Self {
align: Align::Left,
justify_last: Align::Left,
leading: 0.0,
width: None,
rtl: None,
indent_start: 0.0,
indent_end: 0.0,
indent_first: 0.0,
space_before: 0.0,
space_after: 0.0,
every_line: false,
hanging: false,
}
}
}
fn hf(h: &mut impl Hasher, v: f32) {
@ -93,19 +182,23 @@ impl Hash for TextStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
self.family.hash(h);
self.style.hash(h);
hf(h, self.size);
hf(h, self.tracking);
hf(h, self.baseline_shift);
(self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);
for v in [self.size, self.tracking, self.baseline_shift, self.h_scale, self.v_scale, self.tsume] {
hf(h, v);
}
self.manual_kern.map(f32::to_bits).hash(h);
self.leading.map(f32::to_bits).hash(h);
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
}
}
impl Hash for ParagraphStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
self.align.hash(h);
hf(h, self.leading);
(self.align, self.justify_last).hash(h);
for v in [self.leading, self.indent_start, self.indent_end, self.indent_first, self.space_before, self.space_after] {
hf(h, v);
}
self.width.map(f32::to_bits).hash(h);
self.rtl.hash(h);
(self.rtl, self.every_line, self.hanging).hash(h);
}
}
@ -121,6 +214,11 @@ pub struct Glyph {
pub cluster: usize,
pub synth_bold: bool,
pub synth_italic: bool,
/// Horizontal / vertical scale of the outline (1 = 100%).
pub h_scale: f32,
pub v_scale: f32,
/// Index of the style run the glyph belongs to.
pub run: usize,
}
/// One laid-out line.
@ -137,6 +235,10 @@ pub struct Line {
pub glyphs: Range<usize>,
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
pub carets: Vec<(usize, f32)>,
/// Paragraph index.
pub para: usize,
/// The line ends its paragraph.
pub para_end: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
@ -175,7 +277,20 @@ impl Layout {
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
return 0;
};
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
self.hit_line(l, x)
}
/// Nearest caret byte offset to `x` on line `l`. A soft-wrapped line's end is the caret
/// before its trailing space, so clicking past it doesn't land on the next line.
pub fn hit_line(&self, l: &Line, x: f32) -> usize {
let end = self.line_end(l);
l.carets.iter().filter(|c| c.0 <= end).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
}
/// The last caret stop of a line (before a wrapped line's trailing space).
pub fn line_end(&self, l: &Line) -> usize {
if l.para_end {
return l.range.end;
}
l.carets.iter().rev().nth(1).map(|c| c.0).filter(|b| *b >= l.range.start).unwrap_or(l.range.end)
}
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
@ -193,7 +308,10 @@ impl Layout {
}
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
if b > l.range.end {
if x0 > x1 {
x1 = l.x;
}
if b > l.range.end && l.para_end {
x1 += l.ascent * 0.25; // selected newline
}
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
@ -218,9 +336,12 @@ struct ShapedGlyph {
id: u32,
cluster: usize,
adv: f32,
/// Space added before the glyph (manual / optical kerning).
pre: f32,
dx: f32,
dy: f32,
size: f32,
run: usize,
}
struct Item {
@ -239,7 +360,17 @@ fn upper_single(c: char) -> char {
const SMALL_CAPS_SCALE: f32 = 0.78;
fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec<ShapedGlyph> {
/// Horizontal ink extent `(x0, x1)` of a glyph in font units.
fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
let p = crate::outline_units(face, gid)?;
if p.elements().is_empty() {
return None;
}
let r = kurbo::Shape::bounding_box(&*p);
Some((r.x0 as f32, r.x1 as f32))
}
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let shaper = data.shaper(&font).build();
@ -250,7 +381,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
let mut feats = Vec::new();
if !style.kerning {
if !style.kerning || style.optical || style.manual_kern.is_some() {
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
}
if !style.ligatures {
@ -259,7 +390,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
}
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
let k = size / f.units_per_em();
let _ = text;
let hs = style.h_scale;
out.glyph_infos()
.iter()
.zip(out.glyph_positions())
@ -267,77 +398,180 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId
face,
id: i.glyph_id,
cluster: i.cluster as usize,
adv: p.x_advance as f32 * k,
dx: p.x_offset as f32 * k,
adv: p.x_advance as f32 * k * hs,
pre: 0.0,
dx: p.x_offset as f32 * k * hs,
dy: p.y_offset as f32 * k,
size,
run,
})
.collect()
}
/// Tsume, tracking, manual and optical kerning on one shaped item (logical order).
fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_start: bool) {
let side = |g: &ShapedGlyph| -> Option<(f32, f32)> {
let (x0, x1) = ink_x(g.face, g.id)?;
let k = g.size / fonts::face(g.face).units_per_em() * style.h_scale;
Some((x0 * k, g.adv - x1 * k))
};
let is_space = |g: &ShapedGlyph| text[g.cluster..].chars().next().is_none_or(char::is_whitespace);
if style.tsume > 0.0 {
let t = style.tsume.clamp(0.0, 1.0);
for g in glyphs.iter_mut() {
if is_space(g) {
continue;
}
if let Some((lsb, rsb)) = side(g) {
g.dx -= t * lsb.max(0.0);
g.adv -= t * (lsb.max(0.0) + rsb.max(0.0));
}
}
}
if style.optical && glyphs.len() > 1 {
// Pull every pair's ink gap halfway toward the item's median gap.
let sides: Vec<Option<(f32, f32)>> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect();
let gaps: Vec<Option<f32>> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect();
let mut sorted: Vec<f32> = gaps.iter().flatten().copied().collect();
sorted.sort_by(f32::total_cmp);
if let Some(&median) = sorted.get(sorted.len() / 2) {
for (i, gap) in gaps.iter().enumerate() {
if let Some(gap) = gap
&& glyphs[i + 1].cluster != glyphs[i].cluster
{
glyphs[i + 1].pre += (median - gap) * 0.5;
}
}
}
}
if let Some(m) = style.manual_kern {
let k = m * style.size / 1000.0;
for i in 0..glyphs.len() {
let first_of_cluster = i == 0 || glyphs[i - 1].cluster != glyphs[i].cluster;
if first_of_cluster && !(para_start && glyphs[i].cluster == 0) {
glyphs[i].pre += k;
}
}
}
if style.tracking != 0.0 {
let t = style.tracking * style.size / 1000.0;
for k in 0..glyphs.len() {
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
glyphs[k].adv += t;
}
}
}
}
struct ParaLine {
range: Range<usize>,
glyphs: Vec<Glyph>,
carets: Vec<(usize, f32)>,
content: f32,
left_trim: f32,
/// Hanging punctuation widths at the left / right edge.
hang: (f32, f32),
ascent: f32,
descent: f32,
/// Line advance (largest leading on the line).
advance: f32,
align: Align,
avail: Option<f32>,
margins: (f32, f32),
underline: Option<(f32, f32)>,
}
fn is_hanging_close(c: char) -> bool {
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '-' | '\u{2013}' | '\u{2014}' | '\'' | '"' | '\u{2019}' | '\u{201d}' | ')' | '\u{2026}')
}
fn is_hanging_open(c: char) -> bool {
matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}')
}
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
/// paragraphs at the end).
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec<ParaLine> {
let pm = fonts::face(primary.face).metrics(style.size);
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
let rtl_para = para.rtl == Some(true);
let margins_for = |first: bool| -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
};
let avail_for = |first: bool| -> Option<f32> {
let (l, r) = margins_for(first);
width.map(|w| (w - l - r).max(1.0))
};
let style_metrics = |si: usize| {
let st = &runs[si].1;
fonts::face(primaries[si].face).metrics(st.glyph_size())
};
let eff_align = |last: bool| -> Align {
if (last || width.is_none()) && para.align == Align::Justify {
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
} else {
para.align
}
};
if text.is_empty() {
let si = run_at(runs, base);
let pm = style_metrics(si);
return vec![ParaLine {
range: base..base,
glyphs: vec![],
carets: vec![(base, 0.0)],
content: 0.0,
left_trim: 0.0,
hang: (0.0, 0.0),
ascent: pm.ascent,
descent: pm.descent,
advance: runs[si].1.line_advance(),
align: eff_align(true),
avail: avail_for(true),
margins: margins_for(true),
underline: None,
}];
}
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
let bidi = BidiInfo::new(text, default_level);
let pinfo = &bidi.paragraphs[0];
let base_rtl = pinfo.level.is_rtl();
// per char: (byte, char, shaped char), face, level, small
let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text
// per char: (byte, char, shaped char, face, rtl, small caps, run)
let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text
.char_indices()
.map(|(b, c)| {
let si = run_at(runs, base + b);
let style = &runs[si].1;
let (sc, small) = match style.caps {
Caps::Normal => (c, false),
Caps::All => (upper_single(c), false),
Caps::Small if c.is_lowercase() => (upper_single(c), true),
Caps::Small => (c, false),
};
let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) };
(b, c, sc, face, bidi.levels[b].is_rtl(), small)
let primary = primaries[si].face;
let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) };
(b, c, sc, face, bidi.levels[b].is_rtl(), small, si)
})
.collect();
// items: runs of equal (face, rtl, small)
// items: runs of equal (face, rtl, small, style)
let mut items: Vec<Item> = Vec::new();
let mut i = 0;
while i < chars.len() {
let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5);
let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6);
let k0 = key(&chars[i]);
let mut j = i + 1;
while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small {
while j < chars.len() && key(&chars[j]) == k0 {
j += 1;
}
let (face, rtl, small, si) = k0;
let style = &runs[si].1;
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size };
let mut glyphs = shape_item(text, &sub, rtl, face, size, style);
// tracking after each cluster
if style.tracking != 0.0 {
let t = style.tracking * style.size / 1000.0;
for k in 0..glyphs.len() {
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
glyphs[k].adv += t;
}
}
}
let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 };
let mut glyphs = shape_item(&sub, rtl, face, size, style, si);
space_item(&mut glyphs, style, text, true);
let end = if j < chars.len() { chars[j].0 } else { text.len() };
items.push(Item { range: chars[i].0..end, rtl, glyphs });
i = j;
@ -346,7 +580,7 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
let mut char_adv = vec![0.0f32; text.len() + 1];
for it in &items {
for g in &it.glyphs {
char_adv[g.cluster.min(text.len())] += g.adv;
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
}
}
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
@ -362,28 +596,83 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
}
(end, w)
};
// Hanging punctuation widths (left, right) of a line's content.
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
if !para.hanging || width.is_none() || r.start >= ce {
return (0.0, 0.0);
}
let first = text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| char_adv[r.start]);
let last = text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| char_adv[r.start + b]);
(first, last)
};
let fits_w = |r: &Range<usize>| -> f32 {
let (ce, _) = trailing_ws(r);
let (hl, hr) = hang_of(r, ce);
range_w(r.start..ce) - hl - hr
};
// line breaking
let mut ranges: Vec<Range<usize>> = Vec::new();
match para.width {
match width {
None => ranges.push(0..text.len()),
Some(maxw) => {
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for (p, _) in unicode_linebreak::linebreaks(text) {
loop {
let r = start..p;
let (ce, _) = trailing_ws(&r);
let w = range_w(start..ce);
if w <= maxw || last_ok.is_none() {
last_ok = Some(p);
break;
Some(_) => {
let opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect();
let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX);
if para.every_line && opps.len() > 1 && opps.len() <= 1500 {
// Minimise the summed squared slack of every line but the last.
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
let m = nodes.len();
let mut best = vec![(f32::INFINITY, 0usize); m];
best[0] = (0.0, 0);
for j in 1..m {
for i in (0..j).rev() {
if !best[i].0.is_finite() {
continue;
}
let r = nodes[i]..nodes[j];
let w = fits_w(&r);
let maxw = avail(usize::from(i != 0));
let over = w > maxw;
if over && i + 1 != j {
break;
}
let slack = maxw - w;
let cost = if over {
1e12 + w
} else if j == m - 1 {
0.0
} else {
slack * slack
};
let total = best[i].0 + cost;
if total < best[j].0 {
best[j] = (total, i);
}
}
let b = last_ok.take().expect("checked");
ranges.push(start..b);
start = b;
}
let mut j = m - 1;
while j > 0 {
let i = best[j].1;
ranges.push(nodes[i]..nodes[j]);
j = i;
}
ranges.reverse();
} else {
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for p in opps {
loop {
let w = fits_w(&(start..p));
if w <= avail(ranges.len()) || last_ok.is_none() {
last_ok = Some(p);
break;
}
let b = last_ok.take().expect("checked");
ranges.push(start..b);
start = b;
}
}
ranges.push(start..text.len());
}
ranges.push(start..text.len());
ranges.retain(|r| !r.is_empty());
if ranges.is_empty() {
ranges.push(0..text.len());
@ -393,24 +682,28 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
let nlines = ranges.len();
let mut out = Vec::new();
for (li, r) in ranges.into_iter().enumerate() {
let last = li + 1 == nlines;
let align = eff_align(last);
let avail = avail_for(li == 0);
let (ce, tw) = trailing_ws(&r);
let hang = hang_of(&r, ce);
let content = range_w(r.start..ce);
// justification
let mut space_extra = 0.0;
if para.align == Align::Justify
&& li + 1 < nlines
&& let Some(maxw) = para.width
if align == Align::Justify
&& let Some(maxw) = avail
{
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
if spaces > 0 {
space_extra = ((maxw - content) / spaces as f32).max(0.0);
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
}
}
let (levels, runs) = bidi.visual_runs(pinfo, r.clone());
let (levels, vruns) = bidi.visual_runs(pinfo, r.clone());
let mut pen = 0.0f32;
let mut glyphs = Vec::new();
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
for run in runs {
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
let rtl = levels[run.start].is_rtl();
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
if rtl {
@ -418,21 +711,31 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
}
for k in idx {
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
let st = &runs[g.run].1;
let x0 = pen;
pen += g.pre;
glyphs.push(Glyph {
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - style.baseline_shift,
y: -g.dy - st.rise(),
size: g.size,
cluster: base + g.cluster,
synth_bold: primary.synth_bold || style.faux_bold,
synth_italic: primary.synth_italic || style.faux_italic,
synth_bold: primaries[g.run].synth_bold || st.faux_bold,
synth_italic: primaries[g.run].synth_italic || st.faux_italic,
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
pen += space_extra;
}
if st.underline && g.cluster < ce {
let u = underline.get_or_insert((x0, pen));
u.0 = u.0.min(x0);
u.1 = u.1.max(pen);
}
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
e.0 = e.0.min(x0);
e.1 = e.1.max(pen);
@ -475,53 +778,95 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle,
carets.push((base + r.end, end_x));
carets.sort_by_key(|c| c.0);
carets.dedup_by_key(|c| c.0);
let mut asc = pm.ascent;
let mut desc = pm.descent;
// Line metrics: the tallest face and the largest leading on the line.
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
let mut seen: Vec<usize> = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
seen.push(chars.first().map_or(0, |c| c.6));
}
for &si in &seen {
let m = style_metrics(si);
let rise = runs[si].1.rise();
asc = asc.max(m.ascent + rise.max(0.0));
desc = desc.max(m.descent - rise.min(0.0));
adv = adv.max(runs[si].1.line_advance());
}
for g in &glyphs {
if g.face != primary.face {
if g.face != primaries[g.run].face {
let m = fonts::face(g.face).metrics(g.size);
asc = asc.max(m.ascent);
desc = desc.max(m.descent);
}
}
let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content };
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
out.push(ParaLine {
range: base + r.start..base + r.end,
glyphs,
carets,
content: content_w,
left_trim: if base_rtl { tw } else { 0.0 },
hang,
ascent: asc,
descent: desc,
advance: adv,
align,
avail,
margins: margins_for(li == 0),
underline,
});
}
out
}
/// Lay out `text` (paragraphs separated by `\n`).
/// Lay out `text` (paragraphs separated by `\n`) in one style.
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
let primary = fonts::resolve(&style.family, &style.style);
let pm = fonts::face(primary.face).metrics(style.size);
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
/// one) and paragraph settings (one per `\n`-separated paragraph; the last repeats). The wrap
/// width is the first paragraph's.
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
let fallback = [(0..text.len(), TextStyle::default())];
let runs = if runs.is_empty() { &fallback[..] } else { runs };
let default_para = ParagraphStyle::default();
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
let width = para_at(0).width;
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
let mut base = 0usize;
let mut plines = Vec::new();
for p in text.split('\n') {
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
for (pi, p) in text.split('\n').enumerate() {
let p_clean = p.strip_suffix('\r').unwrap_or(p);
plines.extend(paragraph(p_clean, base, style, para, primary));
plines.extend(paragraph(p_clean, base, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l)));
base += p.len() + 1;
}
let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 };
for (i, pl) in plines.into_iter().enumerate() {
let baseline = first_baseline + line_h * i as f32;
let shift = match (para.width, para.align) {
(None, Align::Left | Align::Justify) => 0.0,
(None, Align::Center) => -pl.content / 2.0,
(None, Align::Right) => -pl.content,
(Some(_), Align::Left | Align::Justify) => 0.0,
(Some(w), Align::Center) => (w - pl.content) / 2.0,
(Some(w), Align::Right) => w - pl.content,
} - pl.left_trim;
let mut baseline = 0.0f32;
for (i, (pi, pl)) in plines.into_iter().enumerate() {
let para = para_at(pi);
if i == 0 {
baseline = if width.is_some() { pl.ascent } else { 0.0 };
} else {
let prev_para = lay.lines.last().map_or(pi, |l| l.para);
let mut adv = pl.advance + para.leading;
if prev_para != pi {
adv += para_at(prev_para).space_after + para.space_before;
}
baseline += adv;
}
let shown = pl.content - pl.hang.0 - pl.hang.1;
let (ml, mr) = pl.margins;
let shift = match (pl.avail, pl.align) {
(None, Align::Left | Align::Justify) => ml,
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
(None, Align::Right) => -shown - mr,
(Some(_), Align::Left | Align::Justify) => ml,
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
(Some(a), Align::Right) => ml + a - shown,
} - pl.left_trim
- pl.hang.0;
let g0 = lay.glyphs.len();
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
g.x += shift;
@ -529,8 +874,10 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
g
}));
let x = shift + pl.left_trim;
if style.underline && pl.content > 0.0 {
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
if let Some((u0, u1)) = pl.underline {
let si = run_at(runs, pl.range.start);
let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size);
lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]);
}
lay.lines.push(Line {
range: pl.range,
@ -541,15 +888,20 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
descent: pl.descent,
glyphs: g0..lay.glyphs.len(),
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
para: pi,
para_end: false,
});
}
for i in 0..lay.lines.len() {
lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para);
}
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
for l in &lay.lines {
x0 = x0.min(l.x);
x1 = x1.max(l.x + l.width);
}
if let Some(w) = para.width {
x0 = 0.0;
if let Some(w) = width {
x0 = x0.min(0.0);
x1 = x1.max(w);
}
let first = &lay.lines[0];
@ -563,12 +915,17 @@ fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
C.get_or_init(Default::default)
}
/// Lay out `text` (cached by text and styles).
/// Lay out `text` in one style (cached by text and styles).
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
layout_rich(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
style.hash(&mut h);
para.hash(&mut h);
runs.hash(&mut h);
paras.hash(&mut h);
let key = h.finish();
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@ -576,7 +933,7 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layou
e.0 = now;
return e.1.clone();
}
let l = Arc::new(layout_uncached(text, style, para));
let l = Arc::new(layout_rich_uncached(text, runs, paras));
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
if c.len() >= 512 {
// evict the oldest quarter
@ -720,4 +1077,135 @@ mod tests {
assert_eq!(l.caret(0).0, 0.0);
assert!(l.bounds[3] > l.bounds[1]);
}
fn rich(text: &str, runs: &[(Range<usize>, TextStyle)], para: ParagraphStyle) -> Layout {
layout_rich_uncached(text, runs, &[para])
}
#[test]
fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() {
let text = "small BIG\nnext";
let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))];
let l = rich(text, &runs, ParagraphStyle::default());
assert_eq!(l.lines.len(), 2);
let y0 = l.glyphs[0].y;
assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line");
assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0);
// Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run.
assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline);
assert!(l.lines[0].ascent > 60.0);
// A big run on the second line pushes it down by its 120% leading.
let text2 = "aa\nBB";
let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default());
assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3);
// Explicit leading wins over auto.
let mut fixed = st(20.0);
fixed.leading = Some(50.0);
let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default());
assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3);
}
#[test]
fn super_subscript_tsume_and_kerning_metrics() {
let base = st(100.0);
let sup = TextStyle { script: Script::Super, ..base.clone() };
let sub = TextStyle { script: Script::Sub, ..base.clone() };
let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
let g = &l.glyphs;
assert!((g[1].size - 58.3).abs() < 0.1);
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");
assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered");
// Tsume removes side bearings; full tsume is narrower than none.
let w0 = measure("onion", &base);
let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() });
assert!(w1 < w0 - 5.0, "{w1} vs {w0}");
// Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off.
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() });
let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() });
assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}");
let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() });
assert!(optical > 0.0 && (optical - nokern).abs() < 100.0);
// Horizontal scale stretches advances.
let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() });
assert!((wide - 2.0 * w0).abs() < 0.5);
let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default());
assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5));
}
#[test]
fn indents_and_paragraph_spacing() {
let s = st(20.0);
let text = "first paragraph here\nsecond one";
let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() };
let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]);
assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x);
assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width);
assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3);
assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1));
assert!(l.lines[0].para_end && l.lines[1].para_end);
// Wrapped lines after the first don't get the first-line indent.
let long = "word ".repeat(30);
let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]);
assert!(l.lines.len() > 2);
assert!((l.lines[1].x - 30.0).abs() < 1e-3);
assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5));
assert!(!l.lines[0].para_end);
// Point text: a right-aligned paragraph's end indent moves it left of the origin.
let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]);
assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3);
}
#[test]
fn justify_last_line_modes_and_every_line_composer() {
let text = "the quick brown fox jumps over the lazy dog and keeps running far";
let s = st(30.0);
let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() };
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
let n = left.lines.len();
assert!(n >= 3);
assert!((left.lines[0].width - 320.0).abs() < 0.5);
assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3);
let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]);
let lc = &center.lines[n - 1];
assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5);
let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]);
let lr = &right.lines[n - 1];
assert!((lr.x + lr.width - 320.0).abs() < 0.5);
let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]);
assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line");
// Every-line composer: never worse (summed squared slack) than greedy breaking.
let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() };
let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]);
let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]);
assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy));
assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5));
}
#[test]
fn hanging_punctuation_overhangs_the_box() {
let s = st(40.0);
let text = "\"Hi.\"";
let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x);
let r = layout_rich_uncached(
text,
&[(0..text.len(), s)],
&[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }],
);
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
}
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]);
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
assert_eq!(xs.len(), 4);
assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}");
// Hit testing returns the logical position nearest the point.
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
}
}

View File

@ -11,13 +11,15 @@ pub mod path_text;
pub mod selectors;
pub mod sfnt;
pub use kurbo;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::{Justify, TextDoc};
use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, TextDoc};
pub use fonts::{FaceId, Resolved, families, resolve};
use kurbo::{Affine, BezPath, Point};
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout as layout_text, measure};
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure};
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{GlyphId, MetadataProvider};
@ -43,7 +45,7 @@ impl OutlinePen for Pen {
}
/// Glyph outline in font units (y up), cached.
fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
@ -64,12 +66,14 @@ fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
v
}
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down).
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its
/// horizontal / vertical scale and faux italic.
pub fn glyph_outline(g: &Glyph) -> BezPath {
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
let slant = if g.synth_italic { 0.21 } else { 0.0 };
Affine::new([k, 0.0, slant * k, -k, 0.0, 0.0]) * (*units).clone()
let (hs, vs) = (g.h_scale as f64, g.v_scale as f64);
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone()
}
/// One character of laid-out text.
@ -89,7 +93,10 @@ pub struct CharGlyph {
/// The source character (after All Caps).
pub ch: char,
pub synth_bold: bool,
/// The glyph's drawn size (superscript / small caps are smaller than the run's size).
pub size: f64,
/// Index into [`TextLayout::styles`].
pub run: usize,
}
#[derive(Clone, Debug, Default)]
@ -101,27 +108,111 @@ pub struct TextLayout {
pub lines: usize,
/// Bounds in layer space (x0, y0, x1, y1).
pub bounds: [f64; 4],
/// Line baselines (layer space) and widths for the viewer's text cursor.
/// Line origins (layer space) `[x, baseline, width, height]`.
pub line_boxes: Vec<[f64; 4]>,
/// The character style of each run (glyphs refer to them by index).
pub styles: Vec<CharStyle>,
/// The paragraph layout (layout space) and its map into layer space.
pub layout: Arc<Layout>,
pub to_layer: Affine,
/// Byte offset of every character, plus the text length.
pub byte_of_char: Vec<usize>,
pub vertical: bool,
}
/// A character style → [`TextStyle`].
pub fn text_style(s: &CharStyle) -> TextStyle {
TextStyle {
family: s.font.clone(),
style: s.style.clone(),
size: s.size as f32,
tracking: s.tracking as f32,
kerning: true,
optical: s.kerning == Kerning::Optical,
manual_kern: if let Kerning::Manual(v) = s.kerning { Some(v as f32) } else { None },
ligatures: s.ligatures,
baseline_shift: s.baseline_shift as f32,
faux_bold: s.faux_bold,
faux_italic: s.faux_italic,
caps: if s.all_caps {
Caps::All
} else if s.small_caps {
Caps::Small
} else {
Caps::Normal
},
underline: false,
h_scale: (s.h_scale / 100.0) as f32,
v_scale: (s.v_scale / 100.0) as f32,
tsume: (s.tsume / 100.0) as f32,
script: match s.baseline {
BaselineOption::Normal => Script::Normal,
BaselineOption::Superscript => Script::Super,
BaselineOption::Subscript => Script::Sub,
},
leading: s.leading.map(|l| l as f32),
}
}
/// Paragraph settings → [`ParagraphStyle`] (wrap `width` for paragraph text).
pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> ParagraphStyle {
let (align, justify_last) = match p.justify {
Justify::Left => (Align::Left, Align::Left),
Justify::Center => (Align::Center, Align::Center),
Justify::Right => (Align::Right, Align::Right),
Justify::JustifyLastLeft => (Align::Justify, Align::Left),
Justify::JustifyLastCenter => (Align::Justify, Align::Center),
Justify::JustifyLastRight => (Align::Justify, Align::Right),
Justify::JustifyAll => (Align::Justify, Align::Justify),
};
ParagraphStyle {
align,
justify_last,
leading: 0.0,
width,
// Left-to-right paragraphs keep the Unicode default (first strong character).
rtl: (p.direction == Direction::Rtl).then_some(true),
indent_start: p.indent_left as f32,
indent_end: p.indent_right as f32,
indent_first: p.indent_first as f32,
space_before: p.space_before as f32,
space_after: p.space_after as f32,
every_line: p.composer == Composer::EveryLine,
hanging: p.hanging_punctuation,
}
}
/// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns
/// running right to left), characters kept upright.
const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
/// Lay out a Source Text value in layer space. Point text: the origin is the start of the first
/// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from
/// `box_pos`.
/// `box_pos`. Vertical text runs down from the origin (point) or the box's top-right corner.
pub fn layout_doc(doc: &TextDoc) -> TextLayout {
let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() };
let (style, para) = styles(doc);
let lay = layout_text(&text, &style, &para);
let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0);
// Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0.
let (dx, dy) = match doc.box_size {
Some(_) => (doc.box_pos[0], doc.box_pos[1]),
// The layout already pivots point text around x = 0 by alignment.
None => (0.0, -first_base),
let text = doc.text.as_str();
let byte_of_char: Vec<usize> = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect();
let nchars = byte_of_char.len() - 1;
let mut runs = Vec::new();
let mut styles = Vec::new();
let mut ci = 0;
for r in doc.runs() {
let a = byte_of_char[ci.min(nchars)];
ci += r.len;
let b = byte_of_char[ci.min(nchars)];
runs.push((a..b, text_style(&r.style)));
styles.push(r.style);
}
let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32);
let paras: Vec<ParagraphStyle> = doc.paras().iter().map(|p| paragraph_style(p, width)).collect();
let lay = layout::layout_rich(text, &runs, &paras);
let to_layer = match (doc.vertical, doc.box_size) {
(false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])),
(false, None) => Affine::IDENTITY,
(true, Some(b)) => Affine::translate((doc.box_pos[0] + b[0], doc.box_pos[1])) * ROT90,
(true, None) => ROT90,
};
let hs = doc.h_scale / 100.0;
let vs = doc.v_scale / 100.0;
let mut out = TextLayout { lines: lay.lines.len(), ..Default::default() };
let mut out = TextLayout { lines: lay.lines.len(), to_layer, vertical: doc.vertical, ..Default::default() };
// Map byte clusters → char/word indices.
let mut char_of_byte = vec![0usize; text.len() + 1];
let mut word_of_char = Vec::new();
@ -144,22 +235,30 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
nospace += 1;
}
}
out.chars = text.chars().count();
char_of_byte[text.len()] = nchars;
out.chars = nchars;
out.chars_no_space = nospace;
out.words = words;
let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
for (li, line) in lay.lines.iter().enumerate() {
out.line_boxes.push([line.x as f64 + dx, line.baseline as f64 + dy, line.width as f64, (line.ascent + line.descent) as f64]);
let o = to_layer * Point::new(line.x as f64, line.baseline as f64);
out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]);
for gi in line.glyphs.clone() {
let g = &lay.glyphs[gi];
let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0);
let ch = text[g.cluster..].chars().next().unwrap_or(' ');
let src = text[g.cluster..].chars().next().unwrap_or(' ');
let st = styles.get(g.run);
let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src };
let mut path = glyph_outline(g);
if hs != 1.0 || vs != 1.0 {
path = Affine::scale_non_uniform(hs, vs) * path;
}
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
let origin = Point::new(g.x as f64 * hs + dx, g.y as f64 + dy);
let advance = (next_x - g.x) as f64;
if doc.vertical {
// Keep the character upright around its centre.
let c = Point::new(advance / 2.0, -0.35 * g.size as f64);
let rc = ROT90 * c;
path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path;
}
let origin = to_layer * Point::new(g.x as f64, g.y as f64);
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
b[0] = b[0].min(origin.x + r.x0);
b[1] = b[1].min(origin.y + r.y0);
@ -169,62 +268,102 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
out.glyphs.push(CharGlyph {
path,
origin,
advance: (next_x - g.x) as f64 * hs,
advance,
char_index: ci,
char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0),
word_index: word_of_char.get(ci).copied().unwrap_or(0),
line_index: li,
is_space: ch.is_whitespace(),
is_space: src.is_whitespace(),
ch,
synth_bold: g.synth_bold,
size: g.size as f64,
run: g.run,
});
}
}
if b[0].is_finite() {
out.bounds = b;
}
out.styles = styles;
out.byte_of_char = byte_of_char;
out.layout = lay;
out
}
/// Character and paragraph styles of a Source Text value.
fn styles(doc: &TextDoc) -> (TextStyle, ParagraphStyle) {
let style = TextStyle {
family: doc.font.clone(),
style: doc.style.clone(),
size: doc.size as f32,
tracking: doc.tracking as f32,
baseline_shift: doc.baseline_shift as f32,
faux_bold: doc.faux_bold,
faux_italic: doc.faux_italic,
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
..Default::default()
};
let align = match doc.justify {
Justify::Left | Justify::JustifyLastLeft => Align::Left,
Justify::Center | Justify::JustifyLastCenter => Align::Center,
Justify::Right | Justify::JustifyLastRight => Align::Right,
Justify::JustifyAll => Align::Justify,
};
let natural = doc.size * 1.2;
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
(style, ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None })
impl TextLayout {
/// Character index of byte offset `b`.
pub fn char_of_byte(&self, b: usize) -> usize {
self.byte_of_char.partition_point(|x| *x < b).min(self.chars)
}
fn byte(&self, ci: usize) -> usize {
self.byte_of_char.get(ci.min(self.chars)).copied().unwrap_or(0)
}
/// Layout line holding caret `ci`.
pub fn line_of_char(&self, ci: usize) -> usize {
self.layout.line_of(self.byte(ci))
}
/// The caret at character boundary `ci` as a segment (top, bottom) in layer space.
pub fn caret(&self, ci: usize) -> (Point, Point) {
let (x, base, li) = self.layout.caret(self.byte(ci));
let (asc, desc) = self.layout.lines.get(li).map_or((10.0, 3.0), |l| (l.ascent, l.descent));
(self.to_layer * Point::new(x as f64, (base - asc) as f64), self.to_layer * Point::new(x as f64, (base + desc) as f64))
}
/// The caret's position along its line (layout space), for up / down movement.
pub fn caret_x(&self, ci: usize) -> f64 {
self.layout.caret(self.byte(ci)).0 as f64
}
/// The character boundary nearest a layer-space point.
pub fn hit(&self, p: Point) -> usize {
let q = self.to_layer.inverse() * p;
self.char_of_byte(self.layout.hit(q.x as f32, q.y as f32))
}
/// The caret one line up (`dir` < 0) or down from `ci`, keeping the line position `goal`.
/// Past the first / last line it goes to the start / end of the text.
pub fn move_vertical(&self, ci: usize, dir: i32, goal: Option<f64>) -> (usize, f64) {
let goal = goal.unwrap_or_else(|| self.caret_x(ci));
let li = self.line_of_char(ci) as i64 + dir as i64;
if li < 0 {
return (0, goal);
}
match self.layout.lines.get(li as usize) {
Some(l) => (self.char_of_byte(self.layout.hit_line(l, goal as f32)), goal),
None => (self.chars, goal),
}
}
/// Start and end carets of the visual line holding `ci`.
pub fn line_span(&self, ci: usize) -> (usize, usize) {
let li = self.line_of_char(ci);
match self.layout.lines.get(li) {
Some(l) => (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l))),
None => (0, self.chars),
}
}
/// Selection highlight quads (layer space) for characters `a..b`.
pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> {
self.layout
.selection_rects(self.byte(a.min(b)), self.byte(a.max(b)))
.into_iter()
.map(|r| {
let m = |x: f32, y: f32| self.to_layer * Point::new(x as f64, y as f64);
[m(r[0], r[1]), m(r[2], r[1]), m(r[2], r[3]), m(r[0], r[3])]
})
.collect()
}
}
/// Outline (origin on the baseline) and advance of a single character in a Source Text's
/// style: used by Character Offset / Character Value substitutions.
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
let (style, _) = styles(doc);
/// Outline (origin on the baseline) and advance of a single character in a character style:
/// used by Character Offset / Character Value substitutions.
pub fn char_glyph_style(s: &CharStyle, ch: char) -> (BezPath, f64) {
let style = text_style(s);
let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default());
let hs = doc.h_scale / 100.0;
let vs = doc.v_scale / 100.0;
let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) };
let mut path = glyph_outline(g);
if hs != 1.0 || vs != 1.0 {
path = Affine::scale_non_uniform(hs, vs) * path;
}
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0) * hs;
(Affine::translate((-(g.x as f64) * hs, 0.0)) * path, adv)
let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0);
(Affine::translate((-(g.x as f64), 0.0)) * glyph_outline(g), adv)
}
/// [`char_glyph_style`] in a Source Text's base style.
pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) {
char_glyph_style(&doc.base_style(), ch)
}
#[cfg(test)]
@ -244,6 +383,61 @@ mod tests {
assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty()));
}
#[test]
fn styled_runs_carets_and_hit_testing() {
let mut doc = TextDoc { text: "Hello big\nworld".into(), size: 40.0, ..Default::default() };
doc.apply_style(6..9, |s| {
s.size = 80.0;
s.fill = [1.0, 0.0, 0.0, 1.0];
});
let l = layout_doc(&doc);
let g = l.glyphs.iter().find(|g| g.char_index == 7).unwrap();
assert_eq!(l.styles[g.run].size, 80.0);
assert_eq!(l.styles[g.run].fill, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(g.size, 80.0);
let small = l.glyphs.iter().find(|g| g.char_index == 1).unwrap();
assert_eq!(small.origin.y, g.origin.y, "one baseline for mixed sizes");
// Line 2 sits one 40 px auto leading (48 px) below line 1.
let w = l.glyphs.iter().find(|g| g.char_index == 10).unwrap();
assert!((w.origin.y - g.origin.y - 48.0).abs() < 1e-6);
// Every caret hit-tests back to itself; carets advance along each line.
for ci in 0..=doc.char_len() {
let (a, b) = l.caret(ci);
assert!(b.y > a.y);
assert_eq!(l.hit(a.midpoint(b)), ci, "caret {ci}");
}
assert!(l.caret(8).0.x > l.caret(7).0.x);
// Up / down keep the line position; line spans and selections.
let (down, goal) = l.move_vertical(2, 1, None);
assert_eq!(l.line_of_char(down), 1);
assert!((goal - l.caret_x(2)).abs() < 1e-6);
assert_eq!(l.move_vertical(down, -1, Some(goal)).0, 2);
assert_eq!(l.move_vertical(2, -1, None).0, 0);
assert_eq!(l.move_vertical(12, 1, None).0, 15);
assert_eq!(l.line_span(3), (0, 9));
assert_eq!(l.line_span(12), (10, 15));
let q = l.selection_quads(3, 12);
assert_eq!(q.len(), 2);
assert_eq!(l.char_of_byte(doc.byte_of(12)), 12);
}
#[test]
fn paragraph_box_offset_and_vertical_type() {
let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() };
let l = layout_doc(&doc);
let (a, _) = l.caret(0);
assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}");
// Vertical: characters run down a column, carets are horizontal.
let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() });
let ys: Vec<f64> = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect();
assert!(ys.windows(2).all(|w| w[1] > w[0]), "{ys:?}");
let col2 = v.glyphs.iter().find(|g| g.line_index == 1).unwrap();
assert!(col2.origin.x < v.glyphs[0].origin.x - 10.0, "next column to the left");
let (p0, p1) = v.caret(1);
assert!((p0.y - p1.y).abs() < 1e-6 && (p0.x - p1.x).abs() > 5.0);
assert_eq!(v.hit(p0.midpoint(p1)), 1);
}
#[test]
fn centered_point_text_straddles_origin() {
let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };

View File

@ -25,6 +25,7 @@ pub mod timeline;
pub mod tracker;
pub mod viewer;
pub mod viewer_overlays;
pub mod viewer_text;
pub mod viewer_tools;
pub mod waveform;

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;
}

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).

View File

@ -675,7 +675,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied()
};
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
if resp.drag_started()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
@ -1044,6 +1047,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
});
}
if resp.clicked()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
@ -1056,12 +1060,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 | Tool::MaskFeather => {}
Tool::PenAdd => {
if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) {
@ -1113,6 +1112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
if resp.double_clicked()
&& !text_owns
&& app.ui.tool == Tool::Selection
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1)))
@ -1121,22 +1121,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Transform Points).
ov::begin_free_transform(&ctx, l, m);
} else if resp.double_clicked()
&& !text_owns
&& !app.ui.tool.is_pen()
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
match &l.source {
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
effectcraft_engine::project::LayerSource::Text => {
let doc = effectcraft_engine::render::text::source_text(&ectx, l).map(|d| d.text).unwrap_or_default();
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (l.id.0, doc)));
}
_ => {}
}
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
{
app.session.open_comp(*item);
}
}
text_edit_overlay(app, ui, &map);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
@ -1408,40 +1403,6 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
);
}
/// Inline text editing for a text layer (double-click or Type tool click).
fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap) {
let id = egui::Id::new("viewer-text-edit");
let Some((lid, mut text)) = ui.data(|d| d.get_temp::<(u64, String)>(id)) else { return };
let comp = app.session.active_comp().cloned();
let Some(comp) = comp else { return };
let Some(layer) = comp.layer(LayerId(lid)).cloned() else {
ui.data_mut(|d| d.remove::<(u64, String)>(id));
return;
};
let pos = layer.transform().and_then(|tr| tr.get("position")).map(|p| p.value_at(layer.layer_time(app.session.time())).as_vec2()).unwrap_or([0.0, 0.0]);
let at = map.to_screen(pos) + vec2(-20.0, 10.0);
let mut done = false;
egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(at).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new("Edit text — Enter to commit, Esc to cancel").small());
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_width(320.0).desired_rows(2).font(Tokens::ui(16.0)));
r.request_focus();
if ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift) {
let _ = app.session.execute("layer.setText", json!({"layer": lid, "text": text.trim_end_matches('\n')}));
done = true;
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
done = true;
}
});
});
if done {
ui.data_mut(|d| d.remove::<(u64, String)>(id));
} else {
ui.data_mut(|d| d.insert_temp(id, (lid, text)));
}
}
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter();

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.is_pen() && !type_tool => {
if let Some(e) = text_layer_at(app, ectx, map, p) {
exec(app, "text.edit", json!({"layer": e.layer.id.0}));
ui.memory_mut(|m| m.request_focus(focus_id()));
owns = true;
}
}
_ => {}
}
}
if released || !down {
ui.data_mut(|d| d.remove::<Drag>(drag_id()));
} else if let Some(d) = drag {
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
}
// ---- keyboard
if app.session.state.text_edit.is_some() {
ui.memory_mut(|m| {
m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
});
// A session started elsewhere (menu, agent, Properties) takes the keyboard once.
let seen = egui::Id::new("viewer-text-seen");
let key = app.session.state.text_edit.as_ref().map(|e| e.layer.0);
// …and gets it back whenever nothing else holds it (after a panel click, say).
if ui.data(|d| d.get_temp::<Option<u64>>(seen)).flatten() != key || ui.memory(|m| m.focused().is_none()) {
ui.data_mut(|d| d.insert_temp(seen, key));
ui.memory_mut(|m| m.request_focus(focus_id()));
}
if ui.memory(|m| m.has_focus(focus_id())) {
keyboard(app, ui);
}
} else {
ui.data_mut(|d| d.insert_temp::<Option<u64>>(egui::Id::new("viewer-text-seen"), None));
if ui.memory(|m| m.has_focus(focus_id())) {
ui.memory_mut(|m| m.surrender_focus(focus_id()));
}
}
// ---- overlay
if let Some(s) = app.session.state.text_edit.clone()
&& let Some(e) = edited(app, ectx, s.layer)
{
draw(app, ui, painter, map, &e, s.range(), s.caret);
}
owns
}
fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let events = ui.input(|i| i.events.clone());
let ctx = ui.ctx().clone();
for ev in events {
let Some(sel) = app.session.state.text_edit.clone() else { return };
match ev {
egui::Event::Text(t) if !t.chars().any(char::is_control) => {
let k = typing_key(ui, false);
exec(app, "text.insert", json!({"text": t, "merge": k}));
}
egui::Event::Ime(egui::ImeEvent::Commit(t)) if !t.is_empty() => {
let k = typing_key(ui, false);
exec(app, "text.insert", json!({"text": t, "merge": k}));
ui.data_mut(|d| d.remove::<String>(egui::Id::new("viewer-text-preedit")));
}
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => {
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-preedit"), text));
}
egui::Event::Copy => {
if let Some(v) = exec(app, "edit.copy", json!({}))
&& let Some(t) = v["text"].as_str()
{
ctx.copy_text(t.to_string());
}
}
egui::Event::Cut => {
if let Some(v) = exec(app, "edit.cut", json!({}))
&& let Some(t) = v["text"].as_str()
{
ctx.copy_text(t.to_string());
}
typing_key(ui, true);
}
egui::Event::Paste(t) => {
exec(app, "edit.paste", json!({"text": t}));
typing_key(ui, true);
}
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
use egui::Key;
let cmd = m.command;
let mv = |app: &mut EffectcraftApp, to: &str| {
exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
};
match key {
Key::ArrowLeft | Key::ArrowRight if m.alt => {
// Kerning at the caret, tracking over a selection.
let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
if sel.is_empty() {
if sel.caret < doc.char_len() {
let cur = match doc.style_at(sel.caret).kerning {
Kerning::Manual(v) => v,
_ => 0.0,
};
exec(
app,
"layer.setText",
json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
);
}
} else {
let r = sel.range();
let cur = doc.style_at(r.start).tracking;
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
}
}
Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
Key::Backspace => {
let k = typing_key(ui, false);
exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k}));
}
Key::Delete => {
let k = typing_key(ui, false);
exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k}));
}
Key::Enter if cmd => {
exec(app, "text.endEdit", json!({}));
}
Key::Enter => {
let k = typing_key(ui, false);
exec(app, "text.insert", json!({"text": "\n", "merge": k}));
}
Key::Tab => {
let k = typing_key(ui, false);
exec(app, "text.insert", json!({"text": "\t", "merge": k}));
}
Key::Escape => {
exec(app, "text.endEdit", json!({}));
}
Key::A if cmd => {
exec(app, "text.setSelection", json!({"select": "all"}));
}
Key::Z if cmd => {
let _ = crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
}
Key::Comma | Key::Period if cmd && m.shift => {
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
let r = sel.range();
let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
_ => {}
}
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
typing_key(ui, true);
}
}
_ => {}
}
}
}
fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &ViewerMap, e: &Edited, sel: std::ops::Range<usize>, caret: usize) {
let t = app.tokens;
let to_s = |p: Point| e.to_screen(map, (p.x, p.y));
// Paragraph box and its handles.
if e.doc.box_size.is_some() {
let hs: Vec<Pos2> = e.handles().iter().map(|h| e.to_screen(map, *h)).collect();
let corners = [hs[0], hs[2], hs[4], hs[6], hs[0]];
for w in corners.windows(2) {
painter.line_segment([w[0], w[1]], Stroke::new(1.0, Color32::from_white_alpha(170)));
}
for (i, h) in hs.iter().enumerate() {
let r = Rect::from_center_size(*h, vec2(7.0, 7.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Middle);
app.auto.add(&format!("viewer.textBox.handle.{i}"), r, "Text box handle");
}
}
// Selection highlight.
if !sel.is_empty() {
for q in e.lay.selection_quads(sel.start, sel.end) {
let pts: Vec<Pos2> = q.iter().map(|p| to_s(*p)).collect();
painter.add(egui::Shape::convex_polygon(pts, t.accent.gamma_multiply(0.38), Stroke::NONE));
}
}
// Caret (blinking) and IME composition.
let (a, b) = e.lay.caret(caret);
let (sa, sb) = (to_s(a), to_s(b));
let now = ui.input(|i| i.time);
let preedit: Option<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));
if let Some(pe) = preedit.filter(|s| !s.is_empty()) {
let h = (sb - sa).length().max(10.0);
let g = painter.layout_no_wrap(pe, egui::FontId::proportional(h * 0.7), Color32::WHITE);
let r = Rect::from_min_size(sa, g.size());
painter.rect_filled(r.expand(2.0), 2.0, Color32::from_black_alpha(160));
painter.galley(sa, g, Color32::WHITE);
painter.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, Color32::WHITE));
} else if (now * 2.0) as i64 % 2 == 0 || !sel.is_empty() {
painter.line_segment([sa, sb], Stroke::new(3.0, Color32::from_black_alpha(160)));
painter.line_segment([sa, sb], Stroke::new(1.5, Color32::WHITE));
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
// The text region for automation, and the IME candidate window position.
let r = e.region();
let quad = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|p| e.to_screen(map, p));
let rect = Rect::from_points(&quad);
app.auto.add("viewer.textEdit", rect, &format!("Editing {}", e.layer.name));
let cursor_rect = Rect::from_two_pos(sa, sb).expand2(vec2(1.0, 0.0));
ui.ctx().output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: false });
});
}

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");
}

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

View File

@ -66,7 +66,7 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents |
| 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.~~ (M0.13)
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.