Files
figcraft/crates/engine/src/cmd/text.rs
T
Trevor BrownandClaude Fable 5.1 cd5c9aeb09 M4: a paragraph's base direction can be forced: Text ▸ Text direction (#1830)
Direction came from the letters only; Add right-to-left text says controls override it per paragraph (Text > Text direction, Left to right or Right to left). `TextDirection` on `ParaList`, `Bidi::of` with a base level per paragraph, `text.direction` and the three menu commands (the edited paragraphs while editing), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect `textDirection` / `lineDirections`, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 03:23:07 -05:00

652 lines
32 KiB
Rust

//! Text menu and text editing commands.
use figcraft_doc::{
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath,
TextStyle, VariableId,
};
use serde_json::{Map, Value, json};
use super::props::{styled_name, weight_name};
use super::{CommandSpec, ok};
use crate::params::{boolean, get, id, num, req_id, req_num, req_string, req_targets, string, targets};
use crate::{Result, Session, bad, spec, textedit};
fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
/// Apply a style change to the edited range, or to whole text layers in the selection.
fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value> {
if let Some((id, a, b)) = textedit::selected_range(s) {
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
let bound = super::styles::bound_before(doc, &[id]);
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
if a == b {
t.restyle(0, t.char_count(), &f);
} else {
t.restyle(a, b, &f);
}
}
super::styles::detach_edited(doc, &bound);
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.doc_mut()?;
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
let before = super::styles::bound_before(doc, &ids);
for d in ids {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
super::styles::detach_edited(doc, &before);
ok()
}
fn para(s: &mut Session, p: &Value, f: impl Fn(&mut TextData)) -> Result<Value> {
let ids = match textedit::selected_range(s) {
Some((id, _, _)) => vec![id],
None => req_targets("text", s, p)?,
};
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
f(t);
}
}
}
crate::normalize(doc, &before);
ok()
}
/// Run `f` on each targeted text with the chars whose paragraphs a list command changes: the
/// edited paragraphs while editing, else all of them.
fn para_range(s: &mut Session, p: &Value, f: impl Fn(&mut TextData, usize, usize)) -> Result<Value> {
let range = textedit::selected_range(s);
para(s, p, |t| {
let (a, b) = range.map_or((0, t.char_count()), |(_, a, b)| (a, b));
f(t, a, b)
})
}
/// A list command: every targeted paragraph becomes `l`, or (`toggle`) plain when all already are.
fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Value> {
let range = textedit::selected_range(s);
let ids = range.map_or_else(|| targets(s, p), |(id, _, _)| vec![id]);
let doc = s.doc()?;
let all = ids.iter().all(|i| {
doc.node(*i).and_then(|n| n.kind.text()).is_some_and(|t| {
let (a, b) = range.map_or((0, t.char_count()), |(_, a, b)| (a, b));
t.common_para_list(a, b).is_some_and(|pl| pl.list == l)
})
});
let l = if toggle && all { ListStyle::None } else { l };
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
}
/// The base direction of the targeted paragraphs: the edited ones while editing, else every
/// paragraph of the targeted texts ("controls are available to override text direction per
/// paragraph", Add right-to-left text, #1830).
fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value> {
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
}
/// Whether every targeted char has `pred` (to decide toggle direction).
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
let Ok(doc) = s.doc() else { return false };
if let Some((id, a, b)) = textedit::selected_range(s) {
if let Some(NodeKind::Text(t)) = doc.node(id).map(|n| &n.kind) {
let (a, b) = if a == b { (0, t.char_count()) } else { (a, b) };
return (a..b.max(a + 1)).all(|i| pred(t.style_at(i)));
}
return false;
}
// The texts `style` restyles: the targets and the texts inside them.
let texts: Vec<&TextData> = targets(s, p).iter().flat_map(|i| doc.subtree(*i)).filter_map(|d| doc.node(d).and_then(|n| n.kind.text())).collect();
!texts.is_empty() && texts.iter().all(|t| t.spans().iter().all(|(_, _, st)| pred(st)))
}
fn toggle_bold(s: &mut Session, p: &Value) -> Result<Value> {
let bold = all_have(s, p, |st| st.weight() >= 700);
style(s, p, |st| {
let italic = st.italic();
st.set_font_style(styled_name(if bold { "Regular" } else { "Bold" }, italic));
})
}
fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
let italic = all_have(s, p, TextStyle::italic);
style(s, p, |st| {
let w = weight_name(st.weight());
st.set_font_style(styled_name(w, !italic));
})
}
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
style(s, p, |st| {
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
let italic = st.italic();
st.set_font_style(styled_name(weight_name(w), italic));
})
}
/// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and
/// the path layer is removed. The path's effects move to the text when it has any, and its fills
/// when one of them shows, each with its style and variable bindings; otherwise the text keeps
/// its own.
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.onPath";
let doc = s.doc()?;
let is_text = |i: &NodeId| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)));
let sel = targets(s, p);
let text =
id(p, "text").or_else(|| sel.iter().copied().find(is_text)).ok_or_else(|| bad(CMD, "select a text layer and a path (or pass `text`)"))?;
let path = id(p, "path")
.or_else(|| sel.iter().copied().find(|i| *i != text && !is_text(i)))
.ok_or_else(|| bad(CMD, "select a path or shape too (or pass `path`)"))?;
let pn = doc.get(path)?;
let geom = figcraft_doc::geometry::stroke_geometry(pn).filter(|g| !g.elements().is_empty()).ok_or_else(|| bad(CMD, "that layer has no path"))?;
if !is_text(&text) || doc.is_ancestor(path, text) {
return Err(bad(CMD, "`text` must be a text layer outside the path layer"));
}
let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path));
let (transform, w, h) = (pn.transform, pn.width, pn.height);
let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone()));
let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone()));
let moved = |k: &str| match super::lists::list_binding(k) {
Some((super::lists::BoundList::Fills, ..)) => fills.is_some(),
Some((super::lists::BoundList::Effects, ..)) => effects.is_some(),
_ => false,
};
let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
let mut tp = TextPath::new(&geom);
tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
tp.flip = boolean(p, "flip").unwrap_or(false);
let doc = s.doc_mut()?;
doc.reparent(text, parent, index)?;
doc.remove(path)?;
let n = doc.get_mut(text)?;
n.transform = transform;
(n.width, n.height) = (w, h);
n.bound.retain(|k, _| !moved(k));
n.bound.extend(bound);
if let Some((fills, style)) = fills {
(n.fills, n.styles.fill) = (fills, style);
}
if let Some((effects, style)) = effects {
(n.effects, n.styles.effect) = (effects, style);
}
if let NodeKind::Text(t) = &mut n.kind {
t.path = Some(Box::new(tp));
}
s.set_selection(vec![text]);
Ok(json!({ "id": text }))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"text.onPath",
"Text on path",
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}",
true,
on_path
),
spec!("text.pathOptions", "Text path options", "{start? (0..1 along the path), flip?, ids?} for text on a path", true, |s, p| {
let start = num(p, "start").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0));
let flip = boolean(p, "flip");
para(s, p, |t| {
if let Some(tp) = &mut t.path {
tp.start = start.unwrap_or(tp.start);
tp.flip = flip.unwrap_or(tp.flip);
}
})
}),
spec!("text.removeFromPath", "Remove text from path", "{ids?} text on a path back to a line of auto-width text", true, |s, p| {
para(s, p, |t| {
if t.path.take().is_some() {
t.auto_resize = TextAutoResize::WidthAndHeight;
}
})
}),
spec!("text.bold", "Bold", "{ids?}", true, toggle_bold),
spec!("text.italic", "Italic", "{ids?}", true, toggle_italic),
spec!("text.underline", "Underline", "{ids?}", true, |s, p| {
let on = all_have(s, p, |st| st.decoration == TextDecoration::Underline);
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Underline })
}),
spec!("text.strikethrough", "Strikethrough", "{ids?}", true, |s, p| {
let on = all_have(s, p, |st| st.decoration == TextDecoration::Strikethrough);
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Strikethrough })
}),
spec!(
"text.link",
"Create link",
"{url, ids?} linked text is underlined unless it has a decoration of its own (Underline takes it off again)",
true,
|s, p| {
let url = req_string("text.link", p, "url")?;
style(s, p, |st| {
// "Links are styled with an underline by default" (Add links to text); Underline
// (Cmd/Ctrl+U) removes it, as documented there, and changing the URL of a link
// leaves its decoration as it is.
if st.link.is_none() && st.decoration == TextDecoration::None {
st.decoration = TextDecoration::Underline;
}
st.link = Some(url.clone());
})
}
),
spec!("text.bulletList", "Bulleted list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list(
s,
p,
ListStyle::Bulleted,
true
)),
spec!("text.numberList", "Numbered list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list(
s,
p,
ListStyle::Numbered,
true
)),
spec!("text.alignLeft", "Align left", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Left)),
spec!("text.alignCenter", "Align center", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Center)),
spec!("text.alignRight", "Align right", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Right)),
spec!("text.alignJustify", "Justify", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Justified)),
spec!("text.sizeUp", "Increase font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size + 1.0).min(5000.0))),
spec!("text.sizeDown", "Decrease font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size - 1.0).max(1.0))),
spec!("text.weightUp", "Increase font weight", "{ids?}", true, |s, p| step_weight(s, p, true)),
spec!("text.weightDown", "Decrease font weight", "{ids?}", true, |s, p| step_weight(s, p, false)),
spec!("text.lineHeightUp", "Increase line height", "{ids?}", true, |s, p| {
style(s, p, |st| st.line_height = LineHeight::Pixels(st.line_height.resolve(st.font_size, 1.21).round() + 1.0))
}),
spec!("text.lineHeightDown", "Decrease line height", "{ids?}", true, |s, p| {
style(s, p, |st| st.line_height = LineHeight::Pixels((st.line_height.resolve(st.font_size, 1.21).round() - 1.0).max(0.0)))
}),
spec!("text.trackingUp", "Increase letter spacing", "{ids?}", true, |s, p| {
style(s, p, |st| {
st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
LetterSpacing::Percent(v) => v + 1.0,
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 + 1.0,
})
})
}),
spec!("text.trackingDown", "Decrease letter spacing", "{ids?}", true, |s, p| {
style(s, p, |st| {
st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
LetterSpacing::Percent(v) => v - 1.0,
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 - 1.0,
})
})
}),
spec!("text.caseOriginal", "Original case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Original)),
spec!("text.caseUpper", "Uppercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Upper)),
spec!("text.caseLower", "Lowercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Lower)),
spec!("text.caseTitle", "Title case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Title)),
spec!("text.caseSmallCaps", "Small caps", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::SmallCaps)),
spec!("text.case", "Letter case", "{case: original|upper|lower|title|smallCaps, ids?}", true, |s, p| {
let c: TextCase =
get(p, "case").and_then(super::props::enum_of).ok_or_else(|| bad("text.case", "`case`: original|upper|lower|title|smallCaps"))?;
style(s, p, |st| st.case = c)
}),
spec!("text.decoration", "Decoration", "{decoration: none|underline|strikethrough, ids?}", true, |s, p| {
let d: TextDecoration = get(p, "decoration")
.and_then(super::props::enum_of)
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
style(s, p, |st| st.decoration = d)
}),
spec!(
"text.listStyle",
"List style",
"{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs",
true,
|s, p| {
let l: ListStyle = get(p, "style")
.or_else(|| get(p, "list"))
.and_then(super::props::enum_of)
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
set_list(s, p, l, false)
}
),
spec!(
"text.wrapStyle",
"Wrap style",
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
true,
|s, p| {
let w: figcraft_doc::WrapStyle =
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
para(s, p, |t| t.wrap_style = w)
}
),
spec!(
"text.direction",
"Text direction",
"{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)",
true,
|s, p| {
let d: TextDirection =
get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?;
set_direction(s, p, d)
}
),
spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Auto)
}),
spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Ltr)
}),
spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Rtl)
}),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
}),
spec!("text.paragraphSpacing", "Paragraph spacing", "{spacing (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_spacing = v)
}),
spec!(
"text.maxLines",
"Max lines",
"{lines (0 = no limit; truncates: auto-height and auto-width text hug up to that many lines, fixed-size text becomes a truncating box), ids?}",
true,
|s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
let r = para(s, p, |t| {
t.max_lines = v;
if v > 0 && !matches!(t.auto_resize, TextAutoResize::Height | TextAutoResize::WidthAndHeight) {
t.auto_resize = TextAutoResize::Truncate;
}
})?;
// A text has a max height or a number of max lines, not both (Guide to auto layout).
if v > 0 {
let ids = match textedit::selected_range(s) {
Some((id, _, _)) => vec![id],
None => targets(s, p),
};
let doc = s.doc_mut()?;
for i in ids {
for d in doc.subtree(i) {
if let Some(n) = doc.node_mut(d)
&& n.kind.text().is_some()
{
n.child_layout.max_h = None;
}
}
}
}
Ok(r)
}
),
spec!(
"text.setFeatures",
"OpenType features",
"{features: {tag: bool | null (font default)}, replace? (clear others first), id?/ids?}",
true,
|s, p| {
let f = super::props::features_of("text.setFeatures", get(p, "features").unwrap_or(&Value::Null))?;
let replace = boolean(p, "replace").unwrap_or(false);
para(s, p, |t| {
if replace {
t.features.clear();
}
for (k, on) in &f {
match on {
Some(on) => {
t.features.insert(k.clone(), *on);
}
None => {
t.features.remove(k);
}
}
}
})
}
),
spec!(
"text.setVariations",
"Variable font axes",
"{variations: {axis: number | null (style default)}, replace? (clear others first), id?/ids?}; while editing: the selected range",
true,
|s, p| {
let vars = super::props::variations_of("text.setVariations", get(p, "variations").unwrap_or(&Value::Bool(false)))?;
let replace = boolean(p, "replace").unwrap_or(false);
style(s, p, |st| {
if replace {
st.variations.clear();
}
for (k, v) in &vars {
match v {
Some(v) => {
st.variations.insert(k.clone(), *v);
}
None => {
st.variations.remove(k);
}
}
}
})
}
),
spec!(
"text.preview",
"Preview a change",
"{command: style.apply|library.applyStyle|text.setVariations|text.style|node.set|fill.set|stroke.set|selection.setPaint|style.update, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style, named instance, blend mode, or a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
false,
|s, p| {
let c = req_string("text.preview", p, "command")?;
let params = get(p, "params").cloned().unwrap_or_else(|| json!({}));
crate::textpreview::show(s, &c, &params)?;
Ok(json!({"previewing": crate::textpreview::active(s)}))
}
),
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
Ok(json!({"ended": crate::textpreview::end(s)}))
}),
spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
super::styles::detach_edited(doc, &before);
ok()
}),
spec!(
"text.style",
"Style text range",
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}",
true,
|s, p| {
let ids = req_targets("text.style", s, p)?;
let m = p.as_object().cloned().unwrap_or_default();
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get_mut(i)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
let len = t.char_count();
let a = num(p, "start").map(|v| v.max(0.0) as usize).unwrap_or(0).min(len);
let b = num(p, "end").map(|v| v.max(0.0) as usize).unwrap_or(len).min(len);
for (k, v) in &m {
if matches!(k.as_str(), "id" | "ids" | "start" | "end") {
continue;
}
super::props::apply_text_style("text.style", t, a, b, k, v)?;
}
}
super::styles::detach_edited(doc, &before);
ok()
}
),
spec!("text.edit.begin", "Edit text", "{id, caret? (char index; default select all)}", false, |s, p| {
let id = req_id("text.edit.begin", p, "id")?;
let caret = num(p, "caret").map(|c| c.max(0.0) as usize);
textedit::begin(s, id, caret, false)?;
ok()
}),
spec!("text.edit.insert", "Type text", "{text}", true, |s, p| {
let t = string(p, "text").unwrap_or_default();
textedit::insert(s, &t)?;
ok()
}),
spec!(
"text.edit.key",
"Text key",
"{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Tab|Escape|SelectAll, shift?, alt?, cmd?} in a list, Tab nests the item and Shift+Tab, Enter on an empty item or Backspace at its start move it out",
true,
|s, p| {
let k = req_string("text.edit.key", p, "key")?;
textedit::key(s, &k, boolean(p, "shift").unwrap_or(false), boolean(p, "alt").unwrap_or(false), boolean(p, "cmd").unwrap_or(false))?;
ok()
}
),
spec!("text.listIndent", "Indent list", "{} while editing: the selected list items a level deeper", true, |s, _| {
Ok(json!({"changed": textedit::list_indent(s, false)?}))
}),
spec!("text.listOutdent", "Outdent list", "{} while editing: the selected list items a level out", true, |s, _| {
Ok(json!({"changed": textedit::list_indent(s, true)?}))
}),
spec!("text.edit.caret", "Set caret", "{index, extend?}", false, |s, p| {
let i = req_num("text.edit.caret", p, "index")?.max(0.0) as usize;
textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?;
ok()
}),
spec!("text.edit.selectWord", "Select word", "{index}", false, |s, p| {
let i = finite(req_num("text.edit.selectWord", p, "index")?).max(0.0) as usize;
textedit::select_word(s, i)?;
ok()
}),
spec!("text.edit.selectLine", "Select line", "{index}", false, |s, p| {
let i = finite(req_num("text.edit.selectLine", p, "index")?).max(0.0) as usize;
textedit::select_line(s, i)?;
ok()
}),
spec!(
"text.edit.preedit",
"Compose text (IME)",
"{text (empty = cancel), cursor? (chars into text)}; shown at the caret, committed by text.edit.insert",
false,
|s, p| {
let cursor = num(p, "cursor").map(|c| c.max(0.0) as usize);
textedit::preedit(s, &string(p, "text").unwrap_or_default(), cursor)?;
ok()
}
),
spec!("text.edit.selection", "Text selection", "{}", false, |s, _| {
Ok(match s.ds()?.text_edit.clone() {
Some(te) => json!({
"id": te.id, "caret": te.caret, "anchor": te.anchor, "text": textedit::selected_text(s),
"preedit": te.preedit.map(|p| json!({"text": p.text, "cursor": p.cursor})),
}),
None => Value::Null,
})
}),
spec!("text.edit.end", "Stop editing text", "{}", false, |s, _| {
textedit::end(s)?;
ok()
}),
spec!(
"text.openLink",
"Open link",
"{} while editing: the link at the caret (the selection's start) goes to the UI to open, as Alt+Enter does (\"press Option Return / Alt Enter to activate a focused link\"); only a web or mail link opens → {url} ({url: null}: no link there)",
false,
|s, _| {
let Some(te) = s.ds()?.text_edit.clone() else { return Err(bad("text.openLink", "no text is being edited")) };
let doc = s.doc()?;
let t = doc.node(te.id).and_then(|n| n.kind.text()).ok_or_else(|| bad("text.openLink", format!("{} is not text", te.id)))?;
let n = t.char_count();
let at = te.caret.min(te.anchor);
// The caret sits between two chars: the link of the one after it, else before it.
let link = (at < n)
.then(|| t.style_at(at).link.clone())
.flatten()
.or_else(|| (at > 0 && at <= n).then(|| t.style_at(at - 1).link.clone()).flatten());
match link {
Some(url) if crate::doc_urls::openable(&url) => {
s.request_ui("open.url", json!({"url": url}));
Ok(json!({"url": url}))
}
Some(url) => Err(bad("text.openLink", format!("`{url}` is not a web or mail link"))),
None => Ok(json!({"url": Value::Null})),
}
}
),
spec!(
"text.fonts",
"List fonts",
"{family? (list its styles), style?, features? (the face's OpenType features), axes? (the face's variation axes and named instances)}",
false,
|_, p| {
let face = |f: &str| figcraft_text::find_face(f, &string(p, "style").unwrap_or_else(|| "Regular".into()));
Ok(match string(p, "family") {
Some(f) if boolean(p, "features").unwrap_or(false) => json!(face(&f).map(|face| face.feature_tags()).unwrap_or_default()),
Some(f) if boolean(p, "axes").unwrap_or(false) => match face(&f) {
Some(face) => json!({
"family": face.family,
"axes": face.axes.iter().map(|a| json!({
"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max, "hidden": a.hidden
})).collect::<Vec<_>>(),
"instances": face.instances.iter().map(|i| json!({
"name": i.name,
"variations": face.axes.iter().zip(&i.coords).map(|(a, c)| (a.tag.clone(), json!(c))).collect::<Map<String, Value>>()
})).collect::<Vec<_>>(),
}),
None => Value::Null,
},
Some(f) => json!(figcraft_text::family_styles(&f)),
None => json!(figcraft_text::family_names()),
})
}
),
spec!(
"text.rescanFonts",
"Rescan installed fonts",
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
true,
|s, _| {
let rescan = figcraft_text::rescan_system_fonts();
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
Ok(json!({"added": rescan.added, "refitted": refitted}))
}
),
spec!(
"text.loadFont",
"Load font file",
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
true,
|s, p| {
let cmd = "text.loadFont";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
} else {
return Err(bad(cmd, "pass `path` or `data` (base64)"));
};
let families = figcraft_text::load_font_bytes(bytes);
if families.is_empty() {
return Err(bad(cmd, "not a font file"));
}
let refitted = crate::fonts::refit_open(s, None, Some(&families));
Ok(json!({"families": families, "refitted": refitted}))
}
),
]
}