diff --git a/README.md b/README.md
index 38a77754..c98aab05 100644
--- a/README.md
+++ b/README.md
@@ -98,7 +98,7 @@
Type that sets beautifully
- Text edited in place on the canvas, mixed styles within a paragraph, auto width and auto height, line height, letter and paragraph spacing, decoration, case and text styles, shaped with HarfRust and the open fonts from craft-fonts plus your system fonts.
+ Text edited in place on the canvas (with inline IME composition), mixed styles within a paragraph, auto width and auto height, line height, letter and paragraph spacing, decoration, case, lists, OpenType features, variable font axes and text styles, shaped with HarfRust and the open fonts from craft-fonts plus your system fonts.
Prototype and hand off
diff --git a/ROADMAP.md b/ROADMAP.md
index 919317c5..041cec43 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -45,8 +45,12 @@ dimension measured side by side.
merge, delete vs delete-and-heal, nudging, one undo step per gesture. *Left:* eraser, variable
width strokes, shape builder, transform box for several points, point snapping. 12–18 h.
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
- truncation/max lines, font picker with previews and recents, word/line selection, IME commit.
- *Left:* inline IME preedit, variable font axes, visual QA. 8–12 h.
+ truncation/max lines, font picker with previews and recents, word/line selection, IME commit,
+ inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the
+ document only gets the committed text), variable fonts (axes and named instances read from the
+ font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied
+ to shaping, outlines and PDF), range-aware Typography panel with "Mixed". *Left:* range fills in
+ the Fill section, axis hover preview, right-to-left/bidi text, visual QA. 6–10 h.
3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid
column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text
wraps at its max), ignore auto layout (absolute position) with constraints, text baseline
@@ -127,7 +131,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 |
| Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 |
| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 |
-| Text | 8 | 35% | in-text mouse selection, styled ranges UI, lists, OpenType, variable fonts | 20–30 |
+| Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 |
| Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 |
| Styles & variables | 5 | 45% | style picker everywhere, variable binding UI, aliasing UI, scoping | 10–16 |
| Prototyping | 6 | 30% | noodle editing, overlays, scrolling, smart animate | 20–30 |
@@ -135,7 +139,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
-Weighted feature depth ≈ **50%**.
+Weighted feature depth ≈ **52%**.
## Milestones
diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs
index a24368dc..f9790b58 100644
--- a/crates/doc/src/text.rs
+++ b/crates/doc/src/text.rs
@@ -183,6 +183,10 @@ pub struct TextStyle {
pub fills: Option>,
/// Hyperlink.
pub link: Option,
+ /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`:
+ /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored.
+ #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
+ pub variations: std::collections::BTreeMap,
}
impl Default for TextStyle {
@@ -197,17 +201,34 @@ impl Default for TextStyle {
case: TextCase::Original,
fills: None,
link: None,
+ variations: std::collections::BTreeMap::new(),
}
}
}
+/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values.
+pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"];
+
impl TextStyle {
- /// Numeric weight implied by the style name.
+ /// Numeric weight: a custom `wght` axis value, else the one implied by the style name.
pub fn weight(&self) -> u16 {
- style_weight(&self.font_style)
+ match self.variations.get("wght") {
+ Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16,
+ _ => style_weight(&self.font_style),
+ }
}
pub fn italic(&self) -> bool {
self.font_style.to_ascii_lowercase().contains("italic")
+ || self.variations.get("ital").is_some_and(|v| *v >= 0.5)
+ || self.variations.get("slnt").is_some_and(|v| *v != 0.0)
+ }
+
+ /// Switch to the named style, dropping custom values of the axes the name implies.
+ pub fn set_font_style(&mut self, name: impl Into) {
+ self.font_style = name.into();
+ for tag in STYLE_AXES {
+ self.variations.remove(tag);
+ }
}
}
@@ -273,6 +294,11 @@ pub fn valid_feature_tag(tag: &str) -> bool {
tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b))
}
+/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag).
+pub fn valid_axis_tag(tag: &str) -> bool {
+ valid_feature_tag(tag)
+}
+
impl TextData {
pub fn new(characters: impl Into) -> Self {
Self { characters: characters.into(), ..Default::default() }
@@ -411,4 +437,27 @@ mod case_tests {
assert!(valid_feature_tag("liga") && valid_feature_tag("ss01"));
assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}"));
}
+
+ #[test]
+ fn variations_are_backward_compatible_and_imply_weight() {
+ // Files written before variable fonts load with no custom axes, and stay byte-identical.
+ let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#;
+ let st: TextStyle = serde_json::from_str(old).unwrap();
+ assert!(st.variations.is_empty());
+ assert!(!serde_json::to_string(&st).unwrap().contains("variations"));
+ let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() };
+ st.variations.insert("wght".into(), 640.0);
+ st.variations.insert("wdth".into(), 80.0);
+ assert_eq!(st.weight(), 640);
+ assert!(!st.italic());
+ st.variations.insert("slnt".into(), -8.0);
+ assert!(st.italic());
+ let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
+ assert_eq!(round, st);
+ // Picking a named style drops the axes its name implies, keeps the others.
+ st.set_font_style("Bold");
+ assert_eq!(st.weight(), 700);
+ assert_eq!(st.variations.keys().collect::>(), ["wdth"]);
+ assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
+ }
}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 678f30eb..5603096a 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -550,6 +550,7 @@ fn copy_props_of(doc: &Document, id: NodeId) -> Value {
v["fontFamily"] = json!(t.style.font_family);
v["fontStyle"] = json!(t.style.font_style);
v["fontSize"] = json!(t.style.font_size);
+ v["fontVariations"] = json!(t.style.variations);
v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null);
v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null);
}
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index b5f8d84a..239f7379 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -19,7 +19,8 @@ gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRow
absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), 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 (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, \
+listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
+fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), 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).
@@ -449,7 +450,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map {
+ | "textFills" | "fontVariations" | "variationSettings" => {
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let (start, end) = range_of(props, t.char_count());
@@ -609,7 +610,7 @@ pub fn override_group(k: &str) -> &'static str {
"visible" => "visible",
"name" => "name",
"characters" | "text" => "characters",
- "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" => "textStyle",
+ "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" | "variationSettings" => "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") => {
@@ -652,13 +653,26 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
}
"fontStyle" => {
let st = string_val(v).ok_or_else(|| bad(cmd, "`fontStyle` must be a string"))?;
- t.restyle(start, end, |s| s.font_style = st.clone());
+ t.restyle(start, end, |s| s.set_font_style(st.clone()));
}
"fontWeight" => {
let w = f64_of(v).ok_or_else(|| bad(cmd, "`fontWeight` must be a number"))?;
- let name = weight_name(w as u16);
+ let name = weight_name(w.clamp(1.0, 1000.0) as u16);
t.restyle(start, end, |s| {
- s.font_style = if s.italic() { format!("{name} Italic").replace("Regular Italic", "Italic") } else { name.to_string() }
+ let italic = s.italic();
+ s.set_font_style(styled_name(name, italic));
+ });
+ }
+ "fontVariations" | "variationSettings" => {
+ // Replaces the custom axis values (null or {} = back to what the style name implies).
+ let vars = variations_of(cmd, v)?;
+ t.restyle(start, end, |s| {
+ s.variations.clear();
+ for (k, val) in &vars {
+ if let Some(val) = val {
+ s.variations.insert(k.clone(), *val);
+ }
+ }
});
}
"fontSize" => {
@@ -712,6 +726,37 @@ pub fn features_of(cmd: &str, v: &Value) -> Result)>>
Ok(out)
}
+/// Variable font axis values from `{tag: number | null}` (null = back to what the style implies).
+/// `null` alone means "no custom values".
+pub fn variations_of(cmd: &str, v: &Value) -> Result)>> {
+ let m = match v {
+ Value::Null => return Ok(Vec::new()),
+ Value::Object(m) => m,
+ _ => return Err(bad(cmd, "variations: {axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}")),
+ };
+ let mut out = Vec::with_capacity(m.len());
+ for (k, val) in m {
+ if !valid_axis_tag(k) {
+ return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType axis tag")));
+ }
+ let val = match val {
+ Value::Null => None,
+ other => Some(f64_of(other).filter(|x| x.abs() <= 1e6).ok_or_else(|| bad(cmd, format!("axis `{k}` must be a number or null")))?),
+ };
+ out.push((k.clone(), val));
+ }
+ Ok(out)
+}
+
+/// "Bold" + italic → "Bold Italic"; "Regular" + italic → "Italic".
+pub fn styled_name(weight: &str, italic: bool) -> String {
+ match (italic, weight) {
+ (false, w) => w.to_string(),
+ (true, "Regular") => "Italic".into(),
+ (true, w) => format!("{w} Italic"),
+ }
+}
+
pub fn weight_name(w: u16) -> &'static str {
match w {
0..=149 => "Thin",
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index 61715873..8407fcce 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -3,6 +3,7 @@
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};
@@ -76,31 +77,23 @@ fn toggle_bold(s: &mut Session, p: &Value) -> Result {
let bold = all_have(s, p, |st| st.weight() >= 700);
style(s, p, |st| {
let italic = st.italic();
- let base = if bold { "Regular" } else { "Bold" };
- st.font_style = if italic { if base == "Regular" { "Italic".into() } else { format!("{base} Italic") } } else { base.into() };
+ st.set_font_style(styled_name(if bold { "Regular" } else { "Bold" }, italic));
})
}
fn toggle_italic(s: &mut Session, p: &Value) -> Result {
let italic = all_have(s, p, TextStyle::italic);
style(s, p, |st| {
- let w = super::props::weight_name(st.weight());
- st.font_style = if italic {
- w.to_string()
- } else if w == "Regular" {
- "Italic".into()
- } else {
- format!("{w} Italic")
- };
+ 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 {
style(s, p, |st| {
- let w = st.weight() as i32 + if up { 100 } else { -100 };
- let w = w.clamp(100, 900) as u16;
- let name = super::props::weight_name(w);
- st.font_style = if st.italic() { if name == "Regular" { "Italic".into() } else { format!("{name} Italic") } } else { name.into() };
+ 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));
})
}
@@ -219,6 +212,31 @@ pub fn specs() -> Vec {
})
}
),
+ 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.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)?;
@@ -292,9 +310,23 @@ pub fn specs() -> Vec {
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)}),
+ 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,
})
}),
@@ -305,14 +337,25 @@ pub fn specs() -> Vec {
spec!(
"text.fonts",
"List fonts",
- "{family? (list its styles), features? (with family+style: the face's OpenType features)}",
+ "{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) => {
- 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) 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::>(),
+ "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::>()
+ })).collect::>(),
+ }),
+ None => Value::Null,
+ },
Some(f) => json!(figcraft_text::family_styles(&f)),
None => json!(figcraft_text::family_names()),
})
diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs
index 02976c50..4507ee20 100644
--- a/crates/engine/src/inspect.rs
+++ b/crates/engine/src/inspect.rs
@@ -129,6 +129,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("fontFamily".into(), json!(t.style.font_family));
o.insert("fontStyle".into(), json!(t.style.font_style));
o.insert("fontSize".into(), json!(t.style.font_size));
+ if !t.style.variations.is_empty() {
+ o.insert("fontVariations".into(), json!(t.style.variations));
+ }
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 3b7d6680..eb8a449f 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -189,6 +189,16 @@ pub struct TextEdit {
pub anchor: usize,
/// The node was created by this edit (deleted if left empty).
pub created: bool,
+ /// An IME composition in progress, shown inline at the caret; the document gets it on commit.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub preedit: Option,
+}
+
+/// IME composition text (not yet in the document) and the IME's cursor inside it (chars).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Preedit {
+ pub text: String,
+ pub cursor: usize,
}
/// Per-document editing state.
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index e85ed430..59899e4e 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -249,7 +249,14 @@ fn pointer_draw_move_resize() {
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
// which would fail this test).
- let junk = [json!({}), json!(null), json!({"ids": ["x", -1, 99999]}), json!({"id": "nope", "x": "NaN", "props": 5}), json!([1, 2, 3])];
+ let junk = [
+ json!({}),
+ json!(null),
+ json!({"ids": ["x", -1, 99999]}),
+ json!({"id": "nope", "x": "NaN", "props": 5}),
+ json!([1, 2, 3]),
+ json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}),
+ ];
for spec in all_commands() {
if matches!(
spec.id,
diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs
index 3bec8b3e..29b7e3f7 100644
--- a/crates/engine/src/textedit.rs
+++ b/crates/engine/src/textedit.rs
@@ -1,8 +1,8 @@
//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
-use figcraft_doc::{NodeId, NodeKind, TextData};
+use figcraft_doc::{Document, NodeId, NodeKind, TextData};
-use crate::{EngineError, Result, Session, TextEdit};
+use crate::{EngineError, Preedit, Result, Session, TextEdit};
fn text_of(s: &Session, id: NodeId) -> Result {
match &s.doc()?.get(id)?.kind {
@@ -21,7 +21,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) -
Some(c) => (c.min(n), c.min(n)),
None => (n, 0),
};
- ds.text_edit = Some(TextEdit { id, caret, anchor, created });
+ ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None });
ds.selection = vec![id];
ds.revision += 1;
Ok(())
@@ -64,6 +64,8 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
let n = doc.get_mut(te.id)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
f(t, &mut te);
+ // Any edit ends a composition (a commit replaces it with the final text).
+ te.preedit = None;
let len = t.char_count();
te.caret = te.caret.min(len);
te.anchor = te.anchor.min(len);
@@ -212,28 +214,74 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
}
None => cur,
},
- "SelectAll" => {
- let ds = s.ds_mut()?;
- if let Some(te) = ds.text_edit.as_mut() {
- te.anchor = 0;
- te.caret = n;
- }
- ds.revision += 1;
- return Ok(());
- }
+ "SelectAll" => return select(s, 0, n),
_ => return Ok(()),
};
+ let anchor = if shift { te.anchor } else { next };
+ select(s, anchor, next)
+}
+
+/// Set the selection (`anchor`..`caret`, clamped to the text). Moving the caret abandons any
+/// composition in progress.
+fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> {
+ let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(()) };
+ let len = s.doc()?.node(id).and_then(|n| n.kind.text()).map_or(0, TextData::char_count);
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
- te.caret = next;
- if !shift {
- te.anchor = next;
- }
+ te.anchor = anchor.min(len);
+ te.caret = caret.min(len);
+ te.preedit = None;
}
ds.revision += 1;
Ok(())
}
+/// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars;
+/// default its end). The document doesn't change until the composition is committed with
+/// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as
+/// typing would.
+pub fn preedit(s: &mut Session, text: &str, cursor: Option) -> Result<()> {
+ let Some(te) = s.ds()?.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
+ let text: String = text.chars().filter(|c| !c.is_control()).take(MAX_PREEDIT).collect();
+ if text.is_empty() && te.preedit.is_none() {
+ return Ok(()); // IMEs send empty compositions when idle
+ }
+ if !text.is_empty() && te.preedit.is_none() && te.caret != te.anchor {
+ edit(s, |t, te| {
+ let (a, b) = range(te);
+ t.replace_range(a, b, "");
+ te.caret = a;
+ te.anchor = a;
+ })?;
+ }
+ let n = text.chars().count();
+ let ds = s.ds_mut()?;
+ if let Some(te) = ds.text_edit.as_mut() {
+ te.preedit = (n > 0).then(|| Preedit { text, cursor: cursor.unwrap_or(n).min(n) });
+ }
+ ds.revision += 1;
+ Ok(())
+}
+
+/// Longest composition shown (IMEs compose a phrase at a time).
+const MAX_PREEDIT: usize = 1000;
+
+/// The document as it looks while composing: the composition inserted at the caret, with text
+/// auto-size and layout applied. Also returns the composition's char range and the IME cursor's
+/// index in that document. `None` when nothing is being composed.
+pub fn preedit_doc(s: &Session) -> Option<(Document, (usize, usize), usize)> {
+ let ds = s.ds().ok()?;
+ let te = ds.text_edit.as_ref()?;
+ let pe = te.preedit.as_ref()?;
+ let mut doc = (*ds.doc).clone();
+ let NodeKind::Text(t) = &mut doc.get_mut(te.id).ok()?.kind else { return None };
+ let at = te.caret.min(t.char_count());
+ t.replace_range(at, at, &pe.text);
+ crate::normalize(&mut doc, &ds.doc);
+ let n = pe.text.chars().count();
+ Some((doc, (at, at + n), at + pe.cursor.min(n)))
+}
+
fn line_start(chars: &[char], mut i: usize) -> usize {
while i > 0 && chars.get(i - 1).is_some_and(|c| *c != '\n') {
i -= 1;
@@ -250,20 +298,8 @@ fn line_end(chars: &[char], mut i: usize) -> usize {
/// Place the caret (and optionally extend the selection) at char `index`.
pub fn set_caret(s: &mut Session, index: usize, extend: bool) -> Result<()> {
- let len = match s.ds()?.text_edit.as_ref() {
- Some(te) => text_of(s, te.id).map(|t| t.char_count()).unwrap_or(0),
- None => 0,
- };
- let index = index.min(len);
- let ds = s.ds_mut()?;
- if let Some(te) = ds.text_edit.as_mut() {
- te.caret = index;
- if !extend {
- te.anchor = index;
- }
- }
- ds.revision += 1;
- Ok(())
+ let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
+ select(s, if extend { te.anchor } else { index }, index)
}
/// Select the word at `index` (double-click).
@@ -294,13 +330,7 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
b += 1;
}
}
- let ds = s.ds_mut()?;
- if let Some(te) = ds.text_edit.as_mut() {
- te.anchor = a;
- te.caret = b;
- }
- ds.revision += 1;
- Ok(())
+ select(s, a, b)
}
/// Select the line (paragraph) containing `index` (triple-click).
@@ -308,14 +338,7 @@ pub fn select_line(s: &mut Session, index: usize) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
let chars: Vec = text_of(s, te.id)?.characters.chars().collect();
let i = index.min(chars.len());
- let (a, b) = (line_start(&chars, i), line_end(&chars, i));
- let ds = s.ds_mut()?;
- if let Some(te) = ds.text_edit.as_mut() {
- te.anchor = a;
- te.caret = b;
- }
- ds.revision += 1;
- Ok(())
+ select(s, line_start(&chars, i), line_end(&chars, i))
}
/// The selected text while editing (for copy).
@@ -565,9 +588,111 @@ mod tests {
assert_eq!(data(&s, id).paragraph_indent, 4.0);
}
+ fn preedit_state(s: &Session) -> Option {
+ s.ds().unwrap().text_edit.clone().unwrap().preedit
+ }
+
+ #[test]
+ fn ime_composition_is_shown_but_not_in_the_document_until_committed() {
+ let (mut s, id) = setup("Hello world");
+ let undo_before = s.ds().unwrap().history.undo.len();
+ begin(&mut s, id, Some(5), false).unwrap();
+ let doc_before = s.ds().unwrap().doc.clone();
+ s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap();
+ assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 }));
+ assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone");
+ let (shown, range, cursor) = preedit_doc(&s).unwrap();
+ assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world");
+ assert_eq!((range, cursor), ((5, 7), 6));
+ let info = s.execute("text.edit.selection", &json!({})).unwrap();
+ assert_eq!(info["preedit"]["text"], "にほ");
+ // Updating the composition replaces it; an empty one cancels it.
+ preedit(&mut s, "にほん", None).unwrap();
+ assert_eq!(preedit_doc(&s).unwrap().2, 8);
+ preedit(&mut s, "", None).unwrap();
+ assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none());
+ assert_eq!(data(&s, id).characters, "Hello world");
+ // Commit puts the final text at the caret.
+ preedit(&mut s, "にほん", None).unwrap();
+ s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap();
+ assert!(preedit_state(&s).is_none());
+ assert_eq!(data(&s, id).characters, "Hello日本 world");
+ assert_eq!(sel(&s), (7, 7));
+ // Composing over a selection replaces it; moving the caret abandons the composition.
+ set_caret(&mut s, 7, false).unwrap();
+ set_caret(&mut s, 13, true).unwrap();
+ preedit(&mut s, "x", None).unwrap();
+ assert_eq!(data(&s, id).characters, "Hello日本");
+ set_caret(&mut s, 0, false).unwrap();
+ assert!(preedit_state(&s).is_none());
+ assert_eq!(data(&s, id).characters, "Hello日本");
+ // Ending the edit drops an uncommitted composition; the session stays one undo step.
+ preedit(&mut s, "zz", None).unwrap();
+ end(&mut s).unwrap();
+ assert_eq!(data(&s, id).characters, "Hello日本");
+ assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1);
+ // Not editing: an error, not a crash.
+ assert!(preedit(&mut s, "a", None).is_err());
+ }
+
+ #[test]
+ fn variable_font_axes_as_commands_and_props() {
+ let (mut s, id) = setup("Hello world");
+ let ids = json!([id]);
+ s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap();
+ let st = data(&s, id).style;
+ assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0)));
+ assert_eq!(st.weight(), 550);
+ // null removes one axis; replace clears the rest.
+ s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap();
+ assert_eq!(data(&s, id).style.variations.len(), 1);
+ s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap();
+ assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]);
+ // Undo restores the previous values.
+ s.undo().unwrap();
+ assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]);
+ // Junk is rejected.
+ assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err());
+ assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err());
+ assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err());
+ // node.set props (whole text or a range) replace the custom values.
+ s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap();
+ let t = data(&s, id);
+ assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0));
+ assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0));
+ s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap();
+ assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty());
+ // Picking a named style (Bold, weight steps) drops the custom weight but keeps width.
+ s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap();
+ s.execute("text.bold", &json!({"ids": ids})).unwrap();
+ let st = data(&s, id).style;
+ assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies");
+ assert!(!st.variations.contains_key("wght"));
+ assert_eq!(st.variations.get("wdth"), Some(&90.0));
+ // While editing, the command styles the selected range only.
+ begin(&mut s, id, Some(0), false).unwrap();
+ set_caret(&mut s, 5, true).unwrap();
+ s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap();
+ end(&mut s).unwrap();
+ let t = data(&s, id);
+ assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0));
+ assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0));
+ // The axes query answers without fonts too.
+ let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap();
+ assert!(r.is_null() || r["axes"].is_array());
+ }
+
#[test]
fn text_commands_survive_junk_while_editing() {
- let junk = [json!({}), json!(null), json!({"index": -5, "features": {"": 1}}), json!({"index": 1e300, "style": 3, "case": null}), json!([1])];
+ let junk = [
+ json!({}),
+ json!(null),
+ json!({"index": -5, "features": {"": 1}}),
+ json!({"index": 1e300, "style": 3, "case": null}),
+ json!([1]),
+ json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
+ json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
+ ];
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
for j in &junk {
let (mut s, id) = setup("Hi there\nyou");
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index 8d729ebd..5e458edb 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -118,7 +118,9 @@ fn glyph_runs(
for line in &layout.lines {
let mut run: Option = None;
for g in layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.style == style) {
- let has_source = g.char_end > g.char_start && g.char_end <= chars.len();
+ // Glyphs at a variable font instance other than the default are outlined (an
+ // embedded font draws its default instance).
+ let has_source = g.char_end > g.char_start && g.char_end <= chars.len() && g.coords.is_empty();
let font = if has_source && g.size.is_finite() && g.size > 0.0 && g.x.is_finite() && g.y.is_finite() { ex.font(&g.face) } else { None };
let Some(font) = font else {
// List markers, ellipses and unembeddable fonts: outlines.
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index dec9a413..c401402f 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use figcraft_geom::BezPath;
-use skrifa::instance::{LocationRef, Size};
+use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::{FileRef, TableProvider};
use skrifa::string::StringId;
@@ -37,6 +37,48 @@ impl FontBytes {
static NEXT_FACE: AtomicU32 = AtomicU32::new(1);
+/// One variation axis of a variable font (user-space values).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Axis {
+ /// The 4-char OpenType tag (`wght`, `wdth`, `slnt`, `ital`, `opsz`, or a custom one).
+ pub tag: String,
+ /// The axis name from the font (falls back to a readable name for registered tags).
+ pub name: String,
+ pub min: f64,
+ pub default: f64,
+ pub max: f64,
+ /// The font asks apps not to show this axis.
+ pub hidden: bool,
+}
+
+impl Axis {
+ pub fn clamp(&self, v: f64) -> f64 {
+ if v.is_finite() { v.clamp(self.min, self.max) } else { self.default }
+ }
+}
+
+/// A named instance of a variable font ("Semi Bold", "Condensed Light"…): one user value per axis.
+#[derive(Clone, Debug, PartialEq)]
+pub struct NamedInstance {
+ pub name: String,
+ pub coords: Vec,
+}
+
+/// Normalized variation coordinates to draw a glyph with (empty = the font's default instance).
+pub type Coords = Arc<[NormalizedCoord]>;
+
+/// Readable names for the registered axes (used when the font doesn't name them).
+pub fn axis_label(tag: &str) -> &str {
+ match tag {
+ "wght" => "Weight",
+ "wdth" => "Width",
+ "slnt" => "Slant",
+ "ital" => "Italic",
+ "opsz" => "Optical size",
+ other => other,
+ }
+}
+
/// One loaded font face.
pub struct Face {
pub id: u32,
@@ -59,9 +101,12 @@ pub struct Face {
pub shaper: harfrust::ShaperData,
/// OpenType GSUB feature tags the face provides (e.g. `smcp`).
gsub_features: Vec<[u8; 4]>,
+ /// Variation axes (empty for static fonts) and named instances.
+ pub axes: Vec,
+ pub instances: Vec,
charmap: HashMap,
- outlines: Mutex>>,
- advances: Mutex>,
+ outlines: Mutex>>,
+ advances: Mutex>,
}
impl std::fmt::Debug for Face {
@@ -93,6 +138,7 @@ impl Face {
gsub_features.sort_unstable();
gsub_features.dedup();
let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
+ let (axes, instances) = variation_info(&f);
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
let weight = {
@@ -117,6 +163,8 @@ impl Face {
strike_pos: strike,
shaper,
gsub_features,
+ axes,
+ instances,
charmap,
bytes,
outlines: Mutex::new(HashMap::new()),
@@ -146,45 +194,162 @@ impl Face {
harfrust::FontRef::from_index(self.bytes.data(), self.index).ok()
}
- /// Glyph outline in font units, y down.
+ /// Glyph outline in font units, y down, at the default instance.
pub fn outline(&self, gid: u32) -> Arc {
- {
- let cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
- if let Some(p) = cache.get(&gid) {
- return p.clone();
- }
+ self.outline_at(gid, &no_coords())
+ }
+
+ /// Glyph outline in font units, y down, at variation `coords`.
+ pub fn outline_at(&self, gid: u32, coords: &Coords) -> Arc {
+ let key = (gid, coords.clone());
+ if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
+ return p.clone();
}
let mut pen = FlipPen(BezPath::new());
if let Ok(f) = skrifa::FontRef::from_index(self.bytes.data(), self.index)
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
- let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
+ let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::new(coords)), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() > 20_000 {
cache.clear();
}
- cache.insert(gid, p.clone());
+ cache.insert(key, p.clone());
p
}
- /// Horizontal advance in font units.
+ /// Horizontal advance in font units at the default instance.
pub fn advance(&self, gid: u32) -> f64 {
- {
- let cache = self.advances.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
- if let Some(a) = cache.get(&gid) {
- return *a;
- }
+ self.advance_at(gid, &no_coords())
+ }
+
+ /// Horizontal advance in font units at variation `coords`.
+ pub fn advance_at(&self, gid: u32, coords: &Coords) -> f64 {
+ let key = (gid, coords.clone());
+ if let Some(a) = self.advances.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
+ return *a;
}
let adv = skrifa::FontRef::from_index(self.bytes.data(), self.index)
.ok()
- .and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
+ .and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::new(coords)).advance_width(GlyphId::new(gid)))
.map(f64::from)
+ .filter(|a| a.is_finite())
.unwrap_or(self.upem * 0.5);
- self.advances.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(gid, adv);
+ let mut cache = self.advances.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+ if cache.len() > 50_000 {
+ cache.clear();
+ }
+ cache.insert(key, adv);
adv
}
+
+ /// Whether the face is a variable font.
+ pub fn is_variable(&self) -> bool {
+ !self.axes.is_empty()
+ }
+
+ pub fn axis(&self, tag: &str) -> Option<&Axis> {
+ self.axes.iter().find(|a| a.tag == tag)
+ }
+
+ /// The named instance called `style` (case, spaces and hyphens ignored).
+ pub fn instance(&self, style: &str) -> Option<&NamedInstance> {
+ let want = norm_style(style);
+ self.instances.iter().find(|i| norm_style(&i.name) == want)
+ }
+
+ /// The user-space axis values `style` asks of this face, one per axis in axis order (empty for
+ /// static fonts). Starts from the named instance matching the style name (else the weight and
+ /// italic the name implies, with an optical size that follows the font size), then applies the
+ /// style's custom values; every value is clamped to its axis.
+ pub fn axis_values(&self, style: &figcraft_doc::TextStyle) -> Vec {
+ if self.axes.is_empty() {
+ return Vec::new();
+ }
+ let mut v: Vec = self.axes.iter().map(|a| a.default).collect();
+ if let Some(inst) = self.instance(&style.font_style) {
+ for (slot, c) in v.iter_mut().zip(&inst.coords) {
+ *slot = *c;
+ }
+ } else {
+ let own_style = norm_style(&self.style) == norm_style(&style.font_style);
+ let italic = style.font_style.to_ascii_lowercase().contains("italic") && !self.italic;
+ for (slot, a) in v.iter_mut().zip(&self.axes) {
+ match a.tag.as_str() {
+ "wght" if !own_style => *slot = f64::from(figcraft_doc::style_weight(&style.font_style)),
+ "ital" if italic => *slot = 1.0,
+ "slnt" if italic => *slot = a.min,
+ "opsz" if style.font_size.is_finite() => *slot = style.font_size,
+ _ => {}
+ }
+ }
+ }
+ for (slot, a) in v.iter_mut().zip(&self.axes) {
+ if let Some(x) = style.variations.get(&a.tag) {
+ *slot = *x;
+ }
+ *slot = a.clamp(*slot);
+ }
+ v
+ }
+
+ /// Normalized coordinates for user-space `values` (one per axis; empty = default instance).
+ pub fn coords(&self, values: &[f64]) -> Coords {
+ if self.axes.is_empty() || values.len() != self.axes.len() {
+ return no_coords();
+ }
+ let Ok(f) = skrifa::FontRef::from_index(self.bytes.data(), self.index) else { return no_coords() };
+ let settings: Vec<(skrifa::Tag, f32)> = self.axes.iter().zip(values).filter_map(|(a, v)| Some((tag_of(&a.tag)?, *v as f32))).collect();
+ let loc = f.axes().location(settings);
+ let c = loc.coords();
+ if c.iter().all(|x| *x == NormalizedCoord::ZERO) { no_coords() } else { Coords::from(c) }
+ }
+}
+
+/// The default instance's coordinates.
+pub fn no_coords() -> Coords {
+ Coords::from([])
+}
+
+fn tag_of(s: &str) -> Option {
+ <[u8; 4]>::try_from(s.as_bytes()).ok().map(|b| skrifa::Tag::new(&b))
+}
+
+fn norm_style(s: &str) -> String {
+ s.to_lowercase().replace([' ', '-', '_'], "")
+}
+
+/// The axes (user-space ranges) and named instances of a variable font.
+fn variation_info(f: &skrifa::FontRef) -> (Vec, Vec) {
+ let all = f.axes();
+ let axes: Vec = all
+ .iter()
+ .filter_map(|a| {
+ let tag = String::from_utf8(a.tag().to_be_bytes().to_vec()).ok().filter(|t| figcraft_doc::valid_axis_tag(t))?;
+ let (min, default, max) = (f64::from(a.min_value()), f64::from(a.default_value()), f64::from(a.max_value()));
+ if !(min.is_finite() && default.is_finite() && max.is_finite()) || min > max {
+ return None;
+ }
+ let name = name_string(f, a.name_id()).unwrap_or_else(|| axis_label(&tag).to_string());
+ Some(Axis { tag, name, min, default: default.clamp(min, max), max, hidden: a.is_hidden() })
+ })
+ .collect();
+ // A dropped axis would misalign instance coordinates: expose no variations then.
+ if axes.len() != all.len() {
+ return (Vec::new(), Vec::new());
+ }
+ let mut instances: Vec = Vec::new();
+ for inst in f.named_instances().iter().take(512) {
+ let Some(name) = name_string(f, inst.subfamily_name_id()) else { continue };
+ let coords: Vec = inst.user_coords().map(f64::from).collect();
+ if coords.len() != axes.len() || coords.iter().any(|c| !c.is_finite()) || instances.iter().any(|i| norm_style(&i.name) == norm_style(&name)) {
+ continue;
+ }
+ instances.push(NamedInstance { name, coords });
+ }
+ (axes, instances)
}
struct FlipPen(BezPath);
@@ -292,10 +457,24 @@ impl FontDb {
pub fn styles(&mut self, family: &str) -> Vec {
self.load_family(family);
let Some((_, idx)) = self.families.get(&family.to_lowercase()) else { return vec![] };
- let mut v: Vec<(u16, bool, String)> = idx.iter().filter_map(|i| self.faces.get(*i)).map(|f| (f.weight, f.italic, f.style.clone())).collect();
+ // (weight, italic, order in the font, name): named instances keep the font's order.
+ let mut v: Vec<(u16, bool, usize, String)> = Vec::new();
+ for f in idx.iter().filter_map(|i| self.faces.get(*i)) {
+ if f.instances.is_empty() {
+ v.push((f.weight, f.italic, 0, f.style.clone()));
+ continue;
+ }
+ // A variable font offers its named instances as styles.
+ let wght = f.axes.iter().position(|a| a.tag == "wght");
+ for (k, inst) in f.instances.iter().enumerate() {
+ let w = wght.and_then(|i| inst.coords.get(i)).map(|w| w.round().clamp(1.0, 1000.0) as u16).unwrap_or(f.weight);
+ v.push((w, f.italic || inst.name.to_lowercase().contains("italic"), k, inst.name.clone()));
+ }
+ }
v.sort();
- v.dedup_by(|a, b| a.2 == b.2);
- v.into_iter().map(|(_, _, s)| s).collect()
+ let mut seen = std::collections::HashSet::new();
+ v.retain(|x| seen.insert(norm_style(&x.3)));
+ v.into_iter().map(|x| x.3).collect()
}
pub fn has_family(&self, family: &str) -> bool {
@@ -330,13 +509,26 @@ impl FontDb {
self.load_family(family);
let (_, idx) = self.families.get(&family.to_lowercase())?;
let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
- let norm = |s: &str| s.to_lowercase().replace([' ', '-'], "");
- if let Some(f) = faces.iter().find(|f| norm(&f.style) == norm(style)) {
+ let want = norm_style(style);
+ if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| f.instance(style).is_some())) {
return Some((*f).clone());
}
let want_w = i32::from(figcraft_doc::style_weight(style));
let want_i = style.to_lowercase().contains("italic");
- faces.into_iter().min_by_key(|f| (i32::from(f.weight) - want_w).abs() + if f.italic == want_i { 0 } else { 1000 }).cloned()
+ // A variable face covers its whole weight range, and an italic or slant axis can slant it.
+ let cost = |f: &Arc| {
+ let w = f.axis("wght").map(|a| a.clamp(f64::from(want_w)).round() as i32).unwrap_or(i32::from(f.weight));
+ let slants = f.axis("ital").is_some() || f.axis("slnt").is_some_and(|a| a.min < 0.0);
+ let style_cost = if f.italic == want_i {
+ 0
+ } else if want_i && slants {
+ 500
+ } else {
+ 1000
+ };
+ (w - want_w).abs() + style_cost
+ };
+ faces.into_iter().min_by_key(|f| cost(f)).cloned()
}
/// The default face (Inter Regular if embedded, else any system sans).
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 613b2c75..4ea08beb 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -4,14 +4,16 @@ use std::sync::Arc;
use figcraft_doc::{LineHeight, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag};
use figcraft_geom::{Affine, BezPath, Rect};
-use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
+use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
-use crate::fontdb::{Face, db};
+use crate::fontdb::{Coords, Face, db, no_coords};
/// A positioned glyph (pen position on the baseline, layout coordinates, y down).
#[derive(Clone, Debug)]
pub struct Glyph {
pub face: Arc,
+ /// Variable font coordinates (empty = the face's default instance).
+ pub coords: Coords,
pub gid: u32,
pub x: f64,
pub y: f64,
@@ -28,7 +30,7 @@ impl Glyph {
/// The glyph outline placed in layout coordinates.
pub fn path(&self) -> BezPath {
let s = self.size / self.face.upem;
- let mut p = (*self.face.outline(self.gid)).clone();
+ let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone();
p.apply_affine(Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
p
}
@@ -78,6 +80,7 @@ fn natural_ratio(face: &Face) -> f64 {
struct Item {
face: Arc,
+ coords: Coords,
/// Glyph scale relative to the style's size (synthesised small caps).
scale: f64,
gid: u32,
@@ -162,6 +165,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
if first == '\n' {
items.push(Item {
face: face.clone(),
+ coords: no_coords(),
scale: 1.0,
gid: 0,
adv: 0.0,
@@ -300,17 +304,22 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
};
let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0);
let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len());
- if let (Some(st), Some(face)) = (styles.get(st_idx), items.get(*s).map(|it| it.face.clone()).or_else(|| default_face.clone())) {
+ let marker_face = items.get(*s).map(|it| (it.face.clone(), it.coords.clone())).or_else(|| default_face.clone().map(|f| (f, no_coords())));
+ if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) {
let size = sanitize_size(st.font_size);
let k = size / face.upem;
// Numbers end at the same x; bullets start at the gutter's left edge.
- let mut mx =
- if text.list == ListStyle::Numbered { (marker_width - size * 0.5 - marker_advance(&face, &marker, size)).max(0.0) } else { 0.0 };
+ let mut mx = if text.list == ListStyle::Numbered {
+ (marker_width - size * 0.5 - marker_advance(&face, &coords, &marker, size)).max(0.0)
+ } else {
+ 0.0
+ };
for c in marker.chars() {
let gid = face.glyph_for(c);
- let adv = face.advance(gid) * k;
+ let adv = face.advance_at(gid, &coords) * k;
out.glyphs.push(Glyph {
face: face.clone(),
+ coords: coords.clone(),
gid,
x: mx,
y: baseline,
@@ -332,6 +341,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
if it.gid != 0 || !it.is_space() {
out.glyphs.push(Glyph {
face: it.face.clone(),
+ coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
@@ -455,15 +465,17 @@ fn list_gutter(list: ListStyle, chars: &[char], style: Option<&TextStyle>, face:
ListStyle::Bulleted => size * 1.5,
ListStyle::Numbered => {
let paras = chars.iter().filter(|c| **c == '\n').count() + 1;
- let widest = face.map(|f| marker_advance(f, &format!("{paras}."), size)).unwrap_or(size * 0.6 * (paras.to_string().len() + 1) as f64);
+ let widest = face
+ .map(|f| marker_advance(f, &no_coords(), &format!("{paras}."), size))
+ .unwrap_or(size * 0.6 * (paras.to_string().len() + 1) as f64);
(widest + size * 0.5).max(size * 1.5)
}
}
}
-fn marker_advance(face: &Face, s: &str, size: f64) -> f64 {
+fn marker_advance(face: &Face, coords: &Coords, s: &str, size: f64) -> f64 {
let k = size / face.upem;
- s.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum()
+ s.chars().map(|c| face.advance_at(face.glyph_for(c), coords) * k).sum()
}
fn count_paragraphs_before(chars: &[char], pos: usize) -> usize {
@@ -477,7 +489,7 @@ fn add_ellipsis(out: &mut TextLayout, wrap: Option) {
let gid = face.glyph_for('…');
let gid = if gid == 0 { face.glyph_for('.') } else { gid };
let k = last.size / face.upem;
- let adv = face.advance(gid) * k;
+ let adv = face.advance_at(gid, &last.coords) * k;
let limit = wrap.unwrap_or(f64::INFINITY);
// Drop glyphs until the ellipsis fits, then any trailing spaces ("word…", not "word …").
while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.x + g.advance + adv > limit) {
@@ -489,6 +501,7 @@ fn add_ellipsis(out: &mut TextLayout, wrap: Option) {
let x = out.glyphs.last().filter(|_| out.glyphs.len() > line.glyph_start).map(|g| g.x + g.advance).unwrap_or(0.0);
out.glyphs.push(Glyph {
face,
+ coords: last.coords.clone(),
gid,
x,
y: line.baseline,
@@ -555,9 +568,20 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe
if native_smcp {
feats.push(Feature::new(Tag::new(b"smcp"), 1, ..));
}
+ // Variable fonts: the axis values the style asks for (named instance, implied weight, custom).
+ let values = face.axis_values(style);
+ let coords = face.coords(&values);
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new();
if let Some(hb) = face.hb() {
- let shaper = face.shaper.shaper(&hb).build();
+ let instance = (!coords.is_empty()).then(|| {
+ let vars = face
+ .axes
+ .iter()
+ .zip(&values)
+ .filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 }));
+ ShaperInstance::from_variations(&hb, vars)
+ });
+ let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (u, src, _) in &mapped {
buf.add(*u, *src as u32);
@@ -571,7 +595,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe
} else {
for (u, src, _) in &mapped {
let gid = face.glyph_for(*u);
- raw.push((gid, *src as u32, face.advance(gid) as i32, 0, 0));
+ raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0));
}
}
let n = raw.len();
@@ -586,6 +610,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe
let adv = f64::from(xa) * k * scale + if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 };
items.push(Item {
face: face.clone(),
+ coords: coords.clone(),
scale,
gid,
adv,
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
index 53b5248c..86080fa0 100644
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -9,7 +9,9 @@
pub mod fontdb;
mod layout;
-pub use fontdb::{CRAFT_FONTS, Face, FontDb, db, family_names, family_styles, find_face, scan_system_fonts};
+pub use fontdb::{
+ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, family_names, family_styles, find_face, no_coords, scan_system_fonts,
+};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
use figcraft_doc::{Node, NodeKind, TextAlignV, TextAutoResize};
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
index 021a70ba..ff5e0beb 100644
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -257,3 +257,73 @@ fn vertical_alignment_in_fixed_box() {
n.kind = figcraft_doc::NodeKind::Text(t);
assert_eq!(layout_node(&n).unwrap().1, 0.0);
}
+
+/// A variable font with a usable weight axis, if one is installed (craft-fonts or the system).
+fn variable_face() -> Option> {
+ if !have_fonts() {
+ return None;
+ }
+ scan_system_fonts();
+ ["Inter", "Bahnschrift", "Segoe UI Variable", "Roboto Flex", "Noto Sans", "Cantarell", "Source Sans 3", "Open Sans", "Roboto"]
+ .into_iter()
+ .filter_map(|fam| find_face(fam, "Regular").filter(|f| f.family.eq_ignore_ascii_case(fam)))
+ .find(|f| f.axis("wght").is_some_and(|a| a.max - a.min >= 200.0))
+}
+
+#[test]
+fn variable_font_axes_change_shaping_and_outlines() {
+ let Some(face) = variable_face() else {
+ eprintln!("no variable font installed; skipping");
+ return;
+ };
+ let wght = face.axis("wght").cloned().unwrap();
+ let wi = face.axes.iter().position(|a| a.tag == "wght").unwrap();
+ let mut t = TextData::new("Hamburgefonts");
+ t.style.font_family = face.family.clone();
+ t.style.font_style = face.style.clone();
+ t.style.font_size = 40.0;
+ t.style.variations.insert("wght".into(), wght.min);
+ let light = layout(&t, None, None);
+ t.style.variations.insert("wght".into(), wght.max);
+ let heavy = layout(&t, None, None);
+ assert!(heavy.glyphs.iter().filter(|g| g.face.id == face.id).all(|g| !g.coords.is_empty()), "glyphs carry the instance");
+ let first = |l: &TextLayout| l.glyphs.first().map(|g| g.path().elements().to_vec());
+ assert_ne!(first(&light), first(&heavy), "the weight axis changes the outlines");
+ assert!(heavy.width >= light.width, "heavier is not narrower: {} < {}", heavy.width, light.width);
+ // Values clamp to the axis; unknown axes and junk are ignored.
+ t.style.variations.insert("wght".into(), 1e9);
+ t.style.variations.insert("ZZZZ".into(), 5.0);
+ t.style.variations.insert("wdth".into(), f64::NAN);
+ let v = face.axis_values(&t.style);
+ assert_eq!(v.len(), face.axes.len());
+ assert_eq!(v.get(wi).copied(), Some(wght.max));
+ assert!(v.iter().all(|x| x.is_finite()));
+ let _ = layout(&t, None, None);
+ // Without custom values, the style name implies the weight (or picks the named instance).
+ t.style.variations.clear();
+ t.style.font_style = "Bold".into();
+ let v = face.axis_values(&t.style);
+ let want = face.instance("Bold").and_then(|i| i.coords.get(wi).copied()).unwrap_or_else(|| wght.clamp(700.0));
+ assert_eq!(v.get(wi).copied(), Some(want));
+ // Named instances are offered as styles.
+ if face.instances.len() > 1 {
+ assert!(family_styles(&face.family).len() >= 2);
+ }
+ // The default instance needs no coordinates.
+ assert!(face.coords(&face.axes.iter().map(|a| a.default).collect::>()).is_empty());
+}
+
+#[test]
+fn static_fonts_have_no_axes() {
+ if !have_fonts() {
+ return;
+ }
+ let Some(face) = db().write().unwrap().default_face() else { return };
+ if face.is_variable() {
+ return;
+ }
+ let mut st = figcraft_doc::TextStyle::default();
+ st.variations.insert("wght".into(), 900.0);
+ assert!(face.axis_values(&st).is_empty());
+ assert!(face.coords(&[700.0]).is_empty());
+}
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ca8db56c..661dfdf3 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -4,6 +4,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Document, NodeId, NodeKind};
+use figcraft_engine::Preedit;
use figcraft_engine::tools::{Cursor, PointerEvent, PointerKind, ToolId, selection_box};
use figcraft_geom::Point;
use serde_json::json;
@@ -22,6 +23,7 @@ pub struct CanvasState {
space_pan: bool,
middle_pan: Option<(Pos2, f64, f64)>,
pub last_pointer: Option,
+ preedit: Option,
}
impl Default for CanvasState {
@@ -36,10 +38,48 @@ impl Default for CanvasState {
space_pan: false,
middle_pan: None,
last_pointer: None,
+ preedit: None,
}
}
}
+/// What the canvas shows while an IME composition is in progress: the document with the
+/// composition inserted at the caret (the real document only gets it on commit).
+pub struct PreeditView {
+ /// The document, composition and caret it was built from.
+ key: (Arc, Preedit, usize),
+ pub doc: Arc,
+ /// The composition's char range, and the IME cursor, in `doc`'s edited text.
+ pub range: (usize, usize),
+ pub cursor: usize,
+}
+
+impl FigApp {
+ fn preedit_key(&self) -> Option<(Arc, Preedit, usize)> {
+ let ds = self.session.ds().ok()?;
+ let te = ds.text_edit.as_ref()?;
+ Some((ds.doc.clone(), te.preedit.clone()?, te.caret))
+ }
+
+ /// Rebuild the composition view when the document or composition changed.
+ pub fn sync_preedit(&mut self) {
+ if self.preedit_view().is_some() {
+ return;
+ }
+ self.canvas.preedit = self.preedit_key().and_then(|key| {
+ let (doc, range, cursor) = figcraft_engine::textedit::preedit_doc(&self.session)?;
+ Some(PreeditView { key, doc: Arc::new(doc), range, cursor })
+ });
+ }
+
+ /// The composition view, when it matches the current state.
+ pub fn preedit_view(&self) -> Option<&PreeditView> {
+ let p = self.canvas.preedit.as_ref()?;
+ let (doc, pe, caret) = self.preedit_key()?;
+ (Arc::ptr_eq(&p.key.0, &doc) && p.key.1 == pe && p.key.2 == caret).then_some(p)
+ }
+}
+
#[derive(Clone, PartialEq)]
struct JobKey {
doc: usize,
@@ -89,6 +129,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
input(app, ui, &resp);
+ app.sync_preedit();
request_render(app, worker, ppp);
if let Some(done) = worker.poll()
&& done.generation == app.canvas.generation
@@ -165,9 +206,10 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
let (ox, oy) = ds.view.origin();
let w = (rect.width() * ppp).ceil().max(1.0) as u32;
let h = (rect.height() * ppp).ceil().max(1.0) as u32;
- // Skip the text being edited? No: Figma draws it live; the caret is an overlay.
+ // The edited text is drawn live (an IME composition included); the caret is an overlay.
+ let doc = app.preedit_view().map(|p| p.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let key = JobKey {
- doc: Arc::as_ptr(&ds.doc) as usize,
+ doc: Arc::as_ptr(&doc) as usize,
page: ds.page,
origin: ((ox * 1000.0) as i64, (oy * 1000.0) as i64),
scale: (scale * 10000.0) as i64,
@@ -180,7 +222,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
}
app.canvas.generation += 1;
let job = Job {
- doc: ds.doc.clone(),
+ doc,
page: ds.page,
view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) },
outline: ds.view.outlines,
@@ -571,27 +613,46 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
}
}
}
- // Text editing: caret and selection.
+ // Text editing: caret and selection, or the IME composition underlined at the caret.
+ let preedit = app.preedit_view();
+ let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc);
if let Some(te) = editing
- && let Some(n) = doc.node(te.id)
+ && let Some(n) = edoc.node(te.id)
&& let Some((layout, dy)) = figcraft_text::layout_node(n)
{
- let w = doc.world_transform(te.id);
+ let w = edoc.world_transform(te.id);
let to = |x: f64, y: f64| {
let p = w * Point::new(x, y + dy);
app.to_screen(p.x, p.y)
};
- if let Some(q) = node_quad(app, &doc, te.id) {
+ if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(ACCENT, 1.0));
}
let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
- if a != b {
+ if a != b && preedit.is_none() {
for r in layout.selection_rects(a, b) {
let qq = [to(r.x0, r.y0), to(r.x1, r.y0), to(r.x1, r.y1), to(r.x0, r.y1)];
painter.add(Shape::convex_polygon(qq.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE));
}
}
- let (cx, li) = layout.caret(te.caret);
+ if let Some(p) = preedit {
+ for r in layout.selection_rects(p.range.0, p.range.1) {
+ // Underline just below the baseline, across the composed chars only.
+ let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
+ let x1 = layout
+ .glyphs
+ .get(line.glyph_start..line.glyph_end)
+ .unwrap_or(&[])
+ .iter()
+ .filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
+ .map(|g| g.x + g.advance)
+ .fold(r.x0, f64::max);
+ let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
+ painter.line_segment([to(r.x0, y), to(x1, y)], stroke(t.text, 1.0));
+ }
+ }
+ let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
+ let (cx, li) = layout.caret(caret);
if let Some(line) = layout.lines.get(li).or(layout.lines.last()) {
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
if blink || a != b {
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index c6b80902..3aa081df 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -223,6 +223,13 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) {
app.toast(e.to_string());
}
}
+ // The composition shows inline at the caret; the document gets it on Commit.
+ Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
+ let cursor = active_range_chars.as_ref().map(|r| r.start);
+ if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) {
+ app.toast(e.to_string());
+ }
+ }
Event::Paste(t) => {
// Plain text only: the engine normalises line endings and drops control chars.
if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) {
@@ -285,6 +292,7 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) {
app.run("text.edit.selectLine", json!({"index": idx}));
}
}
+ app.sync_preedit();
ime_output(app, ctx);
}
@@ -302,14 +310,16 @@ fn text_shortcut(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
false
}
-/// Tell the platform where the caret is so IME candidate windows open next to it.
+/// Tell the platform where the caret is (inside the composition while composing) so IME
+/// candidate windows open next to it.
fn ime_output(app: &FigApp, ctx: &Context) {
let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) else { return };
- let Some(doc) = app.session.doc().ok() else { return };
+ let preedit = app.preedit_view();
+ let Some(doc) = preedit.map(|p| &*p.doc).or_else(|| app.session.doc().ok()) else { return };
let Some(node) = doc.node(te.id) else { return };
let Some((layout, dy)) = figcraft_text::layout_node(node) else { return };
let w = doc.world_transform(te.id);
- let (cx, li) = layout.caret(te.caret);
+ let (cx, li) = layout.caret(preedit.map(|p| p.cursor).unwrap_or(te.caret));
let (top, h) = layout.lines.get(li).map(|l| (l.top, l.height)).unwrap_or((0.0, 14.0));
let p0 = w * figcraft_geom::Point::new(cx, top + dy);
let p1 = w * figcraft_geom::Point::new(cx, top + h + dy);
diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs
index 96298575..1b68a2d5 100644
--- a/crates/ui-egui/src/typography.rs
+++ b/crates/ui-egui/src/typography.rs
@@ -1,8 +1,9 @@
//! Typography section of the Design panel: font family (searchable, previewed, recent first),
//! style, size (with common sizes), line height, letter spacing, paragraph spacing, resizing,
-//! alignment, and the Type settings popover (decoration, case, lists, indent, truncation and
-//! OpenType features). While text is being edited with a range selected, character edits apply to
-//! the range (`text.style`); otherwise they apply to whole layers. Differing values show "Mixed".
+//! alignment, and the Type settings popover (decoration, case, lists, indent, truncation, OpenType
+//! features and variable font axes). While text is being edited with a range selected, character
+//! edits apply to the range (`text.style`, `text.setVariations`); otherwise they apply to whole
+//! layers. Differing values show "Mixed".
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -39,6 +40,31 @@ const FEATURES: [(&str, &str, bool); 12] = [
const MIXED: &str = "Mixed";
+/// Horizontal space a dropdown takes besides its label (padding and chevron).
+const DROPDOWN_CHROME: f32 = 30.0;
+
+/// A dropdown whose label stays on one line: long names (variable font styles like "SemiBold
+/// SemiCondensed") end with an ellipsis and show in full on hover.
+fn elided_dropdown(ui: &mut Ui, label: &str, width: f32) -> egui::Response {
+ let shown = elide(ui, label, width - DROPDOWN_CHROME);
+ let resp = widgets::dropdown(ui, &shown, width, None);
+ if shown != label { resp.on_hover_text(label) } else { resp }
+}
+
+/// `s` shortened with an ellipsis to fit `max_w` on one line.
+fn elide(ui: &Ui, s: &str, max_w: f32) -> String {
+ let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), regular(11.0), Color32::WHITE).size().x <= max_w;
+ if fits(s) {
+ return s.to_string();
+ }
+ let chars: Vec = s.chars().collect();
+ (1..chars.len())
+ .rev()
+ .map(|n| format!("{}…", chars.iter().take(n).collect::().trim_end()))
+ .find(|c| fits(c))
+ .unwrap_or_else(|| "…".into())
+}
+
/// The value shared by every style, or None when they differ.
fn uniform(styles: &[TextStyle], f: impl Fn(&TextStyle) -> T) -> Option {
let mut it = styles.iter();
@@ -102,7 +128,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
let fstyle = uniform(&styles, |s| s.font_style.clone());
row(ui, 32.0, |ui| {
let w = ui.available_width();
- let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(MIXED), w, None);
+ let resp = elided_dropdown(ui, family.as_deref().unwrap_or(MIXED), w);
egui::Popup::menu(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) {
remember_family(ui.ctx(), &f);
@@ -115,7 +141,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
// Style + size.
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
row(ui, 32.0, |ui| {
- let resp = widgets::dropdown(ui, fstyle.as_deref().unwrap_or(MIXED), half, None);
+ let resp = elided_dropdown(ui, fstyle.as_deref().unwrap_or(MIXED), half);
let fam = family.clone();
egui::Popup::menu(&resp).width(200.0).show(|ui| match &fam {
Some(fam) => {
@@ -143,11 +169,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
// The text editing session already groups undo; just apply.
Some(FieldEdit::Scrub(v, _)) if editing => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))),
Some(FieldEdit::Scrub(v, done)) => {
- let _ = app.session.begin_interaction("Font size");
- let _ = app.session.execute("node.set", &json!({"ids": ids, "props": {"fontSize": v.clamp(1.0, 5000.0)}}));
- if done {
- let _ = app.session.commit_interaction();
- }
+ live(app, "Font size", "node.set", json!({"ids": ids, "props": {"fontSize": v.clamp(1.0, 5000.0)}}), done)
}
None => {}
}
@@ -242,25 +264,47 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
.id(open_id)
.width(264.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
- .show(|ui| type_settings(app, ui, doc, &ids, &styles, &apply_style));
+ .show(|ui| type_settings(app, ui, doc, &ids, &styles, editing, &apply_style));
});
}
-/// The Type settings popover: Basics (decoration, case, list, indent, truncation) and Details
-/// (OpenType features).
+/// Run `cmd` as a live preview while scrubbing; the gesture becomes one undo step when `done`.
+/// (While text is edited, run commands directly: the editing session already groups undo.)
+fn live(app: &mut FigApp, label: &str, cmd: &str, params: Value, done: bool) {
+ let _ = app.session.begin_interaction(label);
+ if let Err(e) = app.session.execute(cmd, ¶ms) {
+ app.toast(e.to_string());
+ }
+ if done {
+ let _ = app.session.commit_interaction();
+ }
+}
+
+/// The variable font the shown styles use (one family, one variable face), if any.
+fn variable_face(styles: &[TextStyle]) -> Option> {
+ let family = uniform(styles, |s| s.font_family.clone())?;
+ let face = figcraft_text::find_face(&family, &styles.first()?.font_style)?;
+ (face.is_variable() && face.family.eq_ignore_ascii_case(&family)).then_some(face)
+}
+
+/// The Type settings popover: Basics (decoration, case, list, indent, truncation), Details
+/// (OpenType features) and, for variable fonts, Variable (axis sliders).
fn type_settings(
app: &mut FigApp,
ui: &mut Ui,
doc: &Document,
ids: &[NodeId],
styles: &[TextStyle],
+ editing: bool,
apply_style: &dyn Fn(&mut FigApp, &str, Value),
) {
let t = Tokens::get(ui.ctx());
let tab_id = Id::new("ty_settings_tab");
- let mut tab: usize = ui.data(|d| d.get_temp(tab_id)).unwrap_or(0);
+ let vf = variable_face(styles);
+ let tabs: &[&str] = if vf.is_some() { &["Basics", "Details", "Variable"] } else { &["Basics", "Details"] };
+ let mut tab: usize = ui.data(|d| d.get_temp(tab_id)).filter(|i| *i < tabs.len()).unwrap_or(0);
ui.horizontal(|ui| {
- for (i, l) in ["Basics", "Details"].iter().enumerate() {
+ for (i, l) in tabs.iter().enumerate() {
let col = if tab == i { t.text } else { t.text_secondary };
let r = ui.add(egui::Label::new(egui::RichText::new(*l).font(semibold(11.5)).color(col)).sense(Sense::click()));
if r.clicked() {
@@ -336,6 +380,8 @@ fn type_settings(
app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)}));
}
});
+ } else if let (2, Some(face)) = (tab, &vf) {
+ variable_axes(app, ui, ids, styles, face, editing);
} else {
// Which features the font has (first style shown).
let face = uniform(styles, |s| (s.font_family.clone(), s.font_style.clone())).and_then(|(f, s)| figcraft_text::find_face(&f, &s));
@@ -357,6 +403,112 @@ fn type_settings(
}
}
+/// Type settings ▸ Variable: a slider and a field per visible axis of the variable font (values
+/// the style name implies show until changed; "Mixed" when the shown styles differ).
+fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle], face: &figcraft_text::Face, editing: bool) {
+ let t = Tokens::get(ui.ctx());
+ widgets::label(ui, &face.family, t.text_secondary);
+ // Each style's axis values; a style drawn by another face (e.g. a separate italic file) is mixed.
+ let per_style: Vec>> = styles
+ .iter()
+ .map(|s| figcraft_text::find_face(&s.font_family, &s.font_style).filter(|f| f.id == face.id).map(|_| face.axis_values(s)))
+ .collect();
+ let w = ui.available_width();
+ let field_w = 52.0;
+ let label_w = 84.0;
+ let slider_w = (w - label_w - field_w - 12.0).max(40.0);
+ for (i, axis) in face.axes.iter().enumerate().filter(|(_, a)| !a.hidden) {
+ let first = per_style.first().cloned().flatten().and_then(|v| v.get(i).copied());
+ let value = first.filter(|v0| per_style.iter().all(|vs| vs.as_ref().and_then(|v| v.get(i)).is_some_and(|v| (v - v0).abs() < 1e-9)));
+ let value = value.map(|v| round_axis(axis, v));
+ // (value, gesture ended, live preview while dragging)
+ let mut set: Option<(Value, bool, bool)> = None;
+ ui.add_space(2.0);
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 6.0;
+ let (lr, _) = ui.allocate_exact_size(vec2(label_w, FIELD_H), Sense::hover());
+ let name = ui.painter().layout(axis.name.clone(), regular(11.0), t.text, label_w);
+ let tip = format!(
+ "{} ({}): {} – {}, default {}. Double-click the slider to go back to the style's value.",
+ axis.name,
+ axis.tag,
+ trim_num(axis.min),
+ trim_num(axis.max),
+ trim_num(axis.default)
+ );
+ ui.interact(lr, Id::new(("ty_axis_label", &axis.tag)), Sense::hover()).on_hover_text(tip);
+ ui.painter().galley(pos2(lr.min.x, lr.center().y - name.size().y / 2.0), name, t.text);
+ match axis_slider(ui, Id::new(("ty_axis_slider", &axis.tag)), axis, value, slider_w) {
+ Some((Some(v), done)) => set = Some((json!(round_axis(axis, v)), done, true)),
+ Some((None, _)) => set = Some((Value::Null, true, false)),
+ None => {}
+ }
+ let step = if axis.max - axis.min > 20.0 { 1.0 } else { 0.1 };
+ match widgets::number_field(ui, Id::new(("ty_axis_field", &axis.tag)), Lead::None, value, "", field_w, step) {
+ Some(FieldEdit::Set(v)) => set = Some((json!(axis.clamp(round_axis(axis, v))), true, false)),
+ Some(FieldEdit::Scrub(v, done)) => set = Some((json!(axis.clamp(round_axis(axis, v))), done, true)),
+ None => {}
+ }
+ });
+ if let Some((v, done, preview)) = set {
+ let params = json!({"ids": ids, "variations": {axis.tag.clone(): v}});
+ if preview && !editing {
+ live(app, "Variable font axis", "text.setVariations", params, done);
+ } else {
+ app.run("text.setVariations", params);
+ }
+ }
+ }
+ if styles.iter().any(|s| !s.variations.is_empty()) {
+ ui.add_space(8.0);
+ let r = ui.add(egui::Label::new(egui::RichText::new("Reset to style").font(regular(11.0)).color(t.accent)).sense(Sense::click()));
+ if r.on_hover_text("Back to the values the font style implies").clicked() {
+ app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true}));
+ }
+ }
+}
+
+/// Axis values snap to whole numbers on wide axes (weight, width…) and tenths on narrow ones.
+fn round_axis(axis: &figcraft_text::Axis, v: f64) -> f64 {
+ if axis.max - axis.min > 20.0 { v.round() } else { (v * 10.0).round() / 10.0 }
+}
+
+/// A horizontal slider over an axis's range with a tick at its default. `value` None = mixed.
+/// Returns the new value (None = double-click: back to the style's value) and whether the
+/// gesture ended.
+fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option, width: f32) -> Option<(Option, bool)> {
+ let t = Tokens::get(ui.ctx());
+ let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
+ let resp = ui.interact(rect, id, Sense::click_and_drag());
+ let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0));
+ let span = (axis.max - axis.min).max(1e-9);
+ let x_of = |v: f64| track.min.x + ((v - axis.min) / span).clamp(0.0, 1.0) as f32 * track.width();
+ let p = ui.painter();
+ p.rect_filled(track, CornerRadius::same(2), t.border);
+ let dx = x_of(axis.default);
+ p.line_segment([pos2(dx, track.min.y - 3.0), pos2(dx, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong));
+ if let Some(v) = value {
+ let x = x_of(v);
+ p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent);
+ p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE);
+ let ring = if resp.hovered() || resp.dragged() { t.accent } else { t.border_strong };
+ p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring));
+ }
+ if resp.double_clicked() {
+ return Some((None, true));
+ }
+ if (resp.dragged() || resp.clicked() || resp.drag_started())
+ && let Some(pos) = resp.interact_pointer_pos()
+ {
+ let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0));
+ return Some((Some(axis.min + f * span), resp.drag_stopped() || resp.clicked()));
+ }
+ if resp.drag_stopped() {
+ return value.map(|v| (Some(v), true));
+ }
+ None
+}
+
fn caption(ui: &mut Ui, s: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(6.0);
@@ -743,6 +895,14 @@ mod tests {
assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None);
}
+ #[test]
+ fn axis_values_round_by_axis_width() {
+ let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false };
+ assert_eq!(round_axis(&axis(100.0, 900.0), 456.6), 457.0);
+ assert_eq!(round_axis(&axis(-10.0, 0.0), -4.26), -4.3);
+ assert_eq!(round_axis(&axis(0.0, 1.0), 0.04), 0.0);
+ }
+
#[test]
fn uniform_reports_mixed() {
let a = TextStyle::default();
diff --git a/docs/mcp.md b/docs/mcp.md
index 1f415e99..744795b5 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -77,6 +77,14 @@ Or in a project's `.mcp.json`:
tools, `color` may also be a hex string and gradients can be written
`{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the
engine's paint JSON.
+- Text props (`fontFamily`, `fontStyle`, `fontSize`, `lineHeight`, `letterSpacing`, `textDecoration`,
+ `textCase`, `textFills`, `fontVariations`…) apply to the whole layer, or to chars `start`..`end`
+ when given (`text.style {id, start, end, …}` does the same). Styled ranges come back as
+ `styledRuns`. Variable fonts: `fontVariations` (alias `variationSettings`) sets axis values in
+ user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies);
+ `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}`
+ lists a font's axes and named instances (named instances are also valid `fontStyle` values).
+ OpenType features: `features {"liga": false, "tnum": true}`.
- Every mutation is one undo step (`batch` makes several one step). A failing command leaves the
document as it was.
- Programmatic calls never open dialogs.