Files
designcraft/crates/engine/src/cmd/text.rs
T

495 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text editing (Type tool) and character/paragraph formatting.
use designcraft_compose as compose;
use designcraft_doc::{CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
use designcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, has_text, has_text_or_frames, id_param, ok, point_param, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "text.placeCaret", "Place Caret", [], None, "{frame, point: [x,y] (spread coords)} — converts empty frames to text frames", has_doc, |s, p| place(s, p, false)),
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| {
place(s, p, false)?;
let st = s.doc_mut()?;
let Some(t) = st.selection.text else { return ok() };
let text = &st.doc.story(t.story).map(|x| x.text.clone()).unwrap_or_default();
let (a, b) = word_bounds(text, t.focus);
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
ok()
}),
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus}", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
let st = s.doc_mut()?;
let len = st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.len();
let a = (p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize).min(len);
let f = (p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)).min(len);
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None });
st.revision += 1;
ok()
}),
cmd!("text.insert", "Type", [], None, "{text} — replaces the selected text", has_text, insert),
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text.take() {
let f = t.frame.or_else(|| st.doc.story(t.story).and_then(|x| x.frames.first().copied()));
st.selection.items = f.into_iter().collect();
}
st.revision += 1;
ok()
}),
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, sel| {
d.set_story_text(sid, &text)?;
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
t.anchor = t.anchor.min(text.len());
t.focus = t.focus.min(text.len());
}
ok()
})
}),
cmd!(
"story.replaceRange",
"Edit Story",
[],
None,
"{story, start, end, text} — replace a byte range keeping surrounding formatting",
has_doc,
|s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
st.replace(a..b, &text);
let len = st.len();
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
t.anchor = t.anchor.min(len);
t.focus = t.focus.min(len);
}
Ok(json!({"length": len}))
})
}
),
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
let cs = s.cache.get(&st.doc, sid, None);
let mut v = st.doc.story_summary(sid).unwrap_or_default();
v["overset"] = json!(cs.overset_at);
v["lines"] = json!(cs.line_count());
Ok(v)
}),
cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?}", has_text_or_frames, fill_placeholder),
cmd!(
"type.char",
"Character Formatting",
[],
None,
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
has_text_or_frames,
|s, p| format_chars(s, p.get("attrs").unwrap_or(p))
),
cmd!(
"type.para",
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
cmd!("type.align", "Align", [], None, "{align: left|center|right|leftJustified|…}", has_text_or_frames, |s, p| {
format_paras(s, &json!({"align": p.get("align").cloned().unwrap_or(json!("left"))}))
}),
cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "left"}))),
cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| format_paras(
s,
&json!({"align": "center"})
)),
cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "right"}))),
cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| format_paras(
s,
&json!({"align": "leftJustified"})
)),
cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))),
cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))),
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
s,
&json!({"capitalization": "allCaps"})
)),
cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)),
cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
]
}
fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
let st = s.active()?;
if let Some(v) = p.get("story").and_then(Value::as_u64) {
return Some(StoryId(v));
}
let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied());
if let Some(f) = frame {
return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story);
}
st.selection.text.map(|t| t.story)
}
/// Story byte at spread point `pt` in text frame `frame`.
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize)> {
let st = s.active()?;
let loc = st.doc.find(frame)?;
let it = st.doc.item_at(&loc)?;
let sid = it.text_frame()?.story;
let xf = st.doc.parent_xf(&loc) * it.xf;
let inner = xf.inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
Some((sid, b))
}
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
// Empty frames become text frames.
let needs_convert = s.doc()?.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned));
if needs_convert {
s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
}
let (sid, b) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
let st = s.doc_mut()?;
let t = match (extend, st.selection.text) {
(true, Some(t)) if t.story == sid => TextSel { focus: b, frame: Some(frame), ..t },
_ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame) },
};
st.selection = Selection::text(t);
st.revision += 1;
Ok(json!({"story": sid.0, "pos": b}))
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let text = if s.prefs.typographers_quotes { smart_quotes(s, &text) } else { text };
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = r.start.min(st.len())..r.end.min(st.len());
st.replace(r.clone(), &text);
let pos = r.start + text.len();
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
Ok(json!({"pos": pos}))
})
}
fn smart_quotes(s: &Session, text: &str) -> String {
if !text.contains(['"', '\'']) {
return text.to_string();
}
let prev = s
.active()
.and_then(|st| {
let t = st.selection.text?;
let story = st.doc.story(t.story)?;
story.text[..t.range().start.min(story.len())].chars().last()
})
.unwrap_or(' ');
let mut out = String::with_capacity(text.len());
let mut last = prev;
for c in text.chars() {
let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}');
let r = match c {
'"' => {
if open {
'\u{201C}'
} else {
'\u{201D}'
}
}
'\'' => {
if open {
'\u{2018}'
} else {
'\u{2019}'
}
}
c => c,
};
out.push(r);
last = c;
}
out
}
pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
let r = t.range();
if let Some(st) = d.story_mut(t.story) {
st.delete(r.clone());
}
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
ok()
})
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let forward = bool_or(p, "forward", false);
let word = bool_or(p, "word", false);
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = if !r.is_empty() {
r
} else if forward {
let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) };
r.start..end
} else {
let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) };
start..r.start
};
st.delete(r.clone());
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
ok()
})
}
pub fn next_char(s: &str, i: usize) -> usize {
s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
pub fn prev_char(s: &str, i: usize) -> usize {
s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0)
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '\'' || c == '’'
}
pub fn next_word(s: &str, i: usize) -> usize {
let mut j = i;
let mut seen = false;
for (k, c) in s[i..].char_indices() {
if is_word(c) {
seen = true;
} else if seen {
return i + k;
}
j = i + k + c.len_utf8();
}
j
}
pub fn prev_word(s: &str, i: usize) -> usize {
let mut seen = false;
for (k, c) in s[..i].char_indices().rev() {
if is_word(c) {
seen = true;
} else if seen {
return k + c.len_utf8();
}
}
0
}
fn word_bounds(s: &str, i: usize) -> (usize, usize) {
let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
(a, b)
}
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let dir = str_param(p, "dir").unwrap_or("right").to_string();
let extend = bool_or(p, "extend", false);
let word = bool_or(p, "word", false);
let st = s.doc()?;
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
let story = st.doc.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = story.text.clone();
let cs = s.cache.get(&st.doc, t.story, None);
let pos = t.focus.min(text.len());
let collapse_to = |left: bool| if left { t.range().start } else { t.range().end };
let new = match dir.as_str() {
"left" if !extend && !t.is_caret() => collapse_to(true),
"right" if !extend && !t.is_caret() => collapse_to(false),
"left" => {
if word {
prev_word(&text, pos)
} else {
prev_char(&text, pos)
}
}
"right" => {
if word {
next_word(&text, pos)
} else {
next_char(&text, pos)
}
}
"up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos),
"lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0),
"lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()),
"storyStart" => 0,
"storyEnd" => text.len(),
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
};
let stm = s.doc_mut()?;
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
stm.revision += 1;
Ok(json!({"pos": new}))
}
fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end))
}
fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize> {
let (fi, x, baseline, _, _) = compose::caret(cs, pos)?;
let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect();
let cur = lines.iter().position(|(i, l)| *i == fi && (l.baseline - baseline).abs() < 0.01 && pos >= l.range.start && pos <= l.range.end)?;
let target = if up { cur.checked_sub(1)? } else { cur + 1 };
let (tf, tl) = lines.get(target)?;
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
}
/// Ranges and stories a formatting command applies to: the text selection, or whole stories of
/// the selected text frames.
fn format_targets(s: &Session) -> Vec<(StoryId, std::ops::Range<usize>)> {
let Some(st) = s.active() else { return vec![] };
if let Some(t) = st.selection.text {
return vec![(t.story, t.range())];
}
let mut v = Vec::new();
for id in &st.selection.items {
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) {
let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0);
if !v.iter().any(|(s, _)| *s == tf.story) {
v.push((tf.story, 0..len));
}
}
}
v
}
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = CharAttrs::default();
if let Some(o) = attrs.as_object() {
for (k, v) in o {
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
}
}
let targets = format_targets(s);
s.edit(|d, _| {
for (sid, r) in &targets {
let Some(st) = d.story_mut(*sid) else { continue };
if r.is_empty() {
// Caret: change the typing format (the empty run at the caret).
let pos = r.start;
if st.is_empty() {
st.chars[0].format.over.merge(&a);
st.rev += 1;
} else {
let _ = pos;
}
continue;
}
st.format_chars(r.clone(), |f| f.over.merge(&a));
}
ok()
})
}
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = ParaAttrs::default();
if let Some(o) = attrs.as_object() {
for (k, v) in o {
a.set_json(k, v).map_err(|e| bad("type.para", e))?;
}
}
let targets = format_targets(s);
s.edit(|d, _| {
for (sid, r) in &targets {
if let Some(st) = d.story_mut(*sid) {
st.format_paras(r.clone(), |p| p.para.merge(&a));
}
}
ok()
})
}
fn step_size(s: &mut Session, delta: f64) -> Result<Value> {
let cur = selection_attrs(s, &json!({}))?;
let size = cur["chars"]["size"].as_f64().unwrap_or(12.0);
format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)}))
}
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let targets = format_targets(s);
let Some((sid, r)) = targets.first().cloned() else { return Ok(Value::Null) };
let story = st.doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let pi = story.para_at(r.start);
let pf = &story.paras[pi];
let (pp, base) = st.doc.styles.resolve_para(pf);
let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) };
let cp = st.doc.styles.resolve_char(&base, cf);
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
}
/// Our own filler text (not Adobe's).
pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \
et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \
Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \
dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \
Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit.";
fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame));
let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) {
Some(t) => t.story,
None => {
if let Some(f) = frame {
s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?;
}
story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))?
}
};
// Fill until overset: repeat the filler sentences.
let mut text = String::new();
let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect();
let doc = s.doc()?.doc.clone();
let mut d2 = (*doc).clone();
for i in 0..400 {
text.push_str(sentences[i % sentences.len()]);
if i % 3 == 2 {
d2.set_story_text(sid, &text)?;
let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default());
if cs.is_overset() {
// Trim to the last sentence that fits.
let cut = cs.overset_at.unwrap_or(text.len());
let keep = text[..cut.min(text.len())].rfind(". ").map(|k| k + 1).unwrap_or(cut);
text.truncate(keep);
break;
}
}
}
s.edit(|d, _| {
d.set_story_text(sid, text.trim_end())?;
ok()
})
}
pub(crate) fn format_targets_pub(s: &Session) -> Vec<(StoryId, std::ops::Range<usize>)> {
format_targets(s)
}