mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
M2: text depth: OpenType features, small caps, lists, title case across runs, truncation; font picker with previews, type settings; text editing selection and IME
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
23c54a6663
commit
b943e12f3d
@@ -16,7 +16,8 @@ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, paddi
|
||||
mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \
|
||||
absolute, minWidth, maxWidth, minHeight, maxHeight, constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
|
||||
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase, paragraphSpacing, listStyle, pointCount, innerRadius, arcStart, arcSweep, \
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
|
||||
listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), pointCount, innerRadius, arcStart, arcSweep, \
|
||||
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
@@ -45,7 +46,7 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
let norm: String = {
|
||||
@@ -358,6 +359,16 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
|
||||
"listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
|
||||
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
|
||||
"features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => {
|
||||
let f = features_of(cmd, v)?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
t.features.clear();
|
||||
for (k, on) in f {
|
||||
if let Some(on) = on {
|
||||
t.features.insert(k, on);
|
||||
}
|
||||
}
|
||||
}
|
||||
"start" | "end" => {}
|
||||
"pointCount" => {
|
||||
let c = f64_of(v).unwrap_or(5.0).clamp(3.0, 120.0) as u32;
|
||||
@@ -488,6 +499,7 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"visible" => "visible",
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => {
|
||||
@@ -556,7 +568,7 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
|
||||
t.restyle(start, end, |s| s.decoration = d);
|
||||
}
|
||||
"textCase" => {
|
||||
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title"))?;
|
||||
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title|smallCaps"))?;
|
||||
t.restyle(start, end, |s| s.case = c);
|
||||
}
|
||||
"hyperlink" => {
|
||||
@@ -572,6 +584,24 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenType feature toggles from `{tag: bool | null}` (null = back to the font's default).
|
||||
pub fn features_of(cmd: &str, v: &Value) -> Result<Vec<(String, Option<bool>)>> {
|
||||
let Value::Object(m) = v else { return Err(bad(cmd, "features: {tag: bool}, e.g. {\"liga\": false}")) };
|
||||
let mut out = Vec::with_capacity(m.len());
|
||||
for (k, on) in m {
|
||||
if !valid_feature_tag(k) {
|
||||
return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType feature tag")));
|
||||
}
|
||||
let on = match on {
|
||||
Value::Null => None,
|
||||
Value::Bool(b) => Some(*b),
|
||||
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("feature `{k}` must be true, false or null")))? != 0.0),
|
||||
};
|
||||
out.push((k.clone(), on));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn weight_name(w: u16) -> &'static str {
|
||||
match w {
|
||||
0..=149 => "Thin",
|
||||
|
||||
@@ -7,6 +7,10 @@ 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) {
|
||||
@@ -154,6 +158,67 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.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)?;
|
||||
@@ -217,15 +282,41 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.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)}),
|
||||
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)}", false, |_, p| {
|
||||
Ok(match string(p, "family") {
|
||||
Some(f) => json!(figcraft_text::family_styles(&f)),
|
||||
None => json!(figcraft_text::family_names()),
|
||||
})
|
||||
}),
|
||||
spec!(
|
||||
"text.fonts",
|
||||
"List fonts",
|
||||
"{family? (list its styles), features? (with family+style: the face's OpenType features)}",
|
||||
false,
|
||||
|_, p| {
|
||||
Ok(match string(p, "family") {
|
||||
Some(f) if boolean(p, "features").unwrap_or(false) => {
|
||||
let st = string(p, "style").unwrap_or_else(|| "Regular".into());
|
||||
json!(figcraft_text::find_face(&f, &st).map(|face| face.feature_tags()).unwrap_or_default())
|
||||
}
|
||||
Some(f) => json!(figcraft_text::family_styles(&f)),
|
||||
None => json!(figcraft_text::family_names()),
|
||||
})
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user