Files
figcraft/crates/engine/src/cmd/text.rs
T
BFlatandClaude Opus 5.5 340b673666 M3: text depth: RTL names, text style browser, axis previews (#72)
Right-to-left and mixed-direction names in the panels: layers, pages,
Assets, tab titles, the file header and canvas frame titles show in
visual order (Unicode bidi, joined Arabic letters, mirrored brackets),
elided at their end (the left end of a right-to-left name). egui shapes
each font run itself, so shown names are laid out a cluster at a time.
Rename fields keep egui's editing and draw the shown text over it: the
caret, selection, clicks, drags and arrow keys follow what's on screen.
System fonts are added for scripts the built-in UI fonts lack.

The Typography section's text style browser: list and grid views with
each style in its own font, search, folders, this file's and enabled
libraries' styles, apply, detach, edit the typography, create from the
selected chars, hover to preview on the canvas, and "Mixed" when the
chars use several styles. Text styles now link chars (style_id on text
styles), so a range can take a style; editing what a style sets
unlinks the chars edited; files from before link on open.

Live text previews (text.preview / text.endPreview): a hovered text
style, font style or named instance and a dragged axis slider show on
the canvas without an undo step; release commits one step, Escape
cancels. ui.input sends raw window input over the control channel.

Visual QA: list markers keep the paragraph's font, renames start with
the name selected and centred, Max lines shows while truncating.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:03:09 +07:00

381 lines
18 KiB
Rust

//! Text menu and text editing commands.
use figcraft_doc::*;
use serde_json::{Map, Value, json};
use super::props::{styled_name, weight_name};
use super::{CommandSpec, ok};
use crate::params::*;
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();
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);
}
}
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.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) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
}
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()
}
/// 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;
}
targets(s, p).iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if 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));
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
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?}", true, |s, p| {
let url = req_string("text.link", p, "url")?;
style(s, p, |st| st.link = Some(url.clone()))
}),
spec!("text.bulletList", "Bulleted list", "{ids?}", true, |s, p| para(s, p, |t| t.list =
if t.list == ListStyle::Bulleted { ListStyle::None } else { ListStyle::Bulleted })),
spec!("text.numberList", "Numbered list", "{ids?}", true, |s, p| para(s, p, |t| t.list =
if t.list == ListStyle::Numbered { ListStyle::None } else { ListStyle::Numbered })),
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: none|bulleted|numbered, ids?}", 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"))?;
para(s, p, |t| t.list = l)
}),
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), ids?}", true, |s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 {
t.auto_resize = TextAutoResize::Truncate;
}
})
}),
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 text change",
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, 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}", 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()?;
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
ok()
}),
spec!(
"text.style",
"Style text range",
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, 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()?;
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)?;
}
}
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|Escape|SelectAll, shift?, alt?, cmd?}",
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.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.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()),
})
}
),
]
}