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:
Brandon Thomas 2026-10-09 14:15:36 -04:00
parent 23c54a6663
commit b943e12f3d
9 changed files with 1576 additions and 222 deletions

View File

@ -86,28 +86,42 @@ pub enum TextCase {
impl TextCase {
pub fn apply(self, s: &str) -> String {
self.apply_after(s, None)
}
/// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises
/// the first letter of each word even when a word spans several styled runs).
pub fn apply_after(self, s: &str, prev: Option<char>) -> String {
match self {
TextCase::Original | TextCase::SmallCaps => s.to_string(),
TextCase::Upper => s.to_uppercase(),
TextCase::Lower => s.to_lowercase(),
TextCase::Title => {
let mut out = String::with_capacity(s.len());
let mut start = true;
let mut start = prev.is_none_or(|c| !is_word_char(c));
for ch in s.chars() {
if start && ch.is_alphabetic() {
if start && ch.is_alphanumeric() {
out.extend(ch.to_uppercase());
start = false;
} else {
out.push(ch);
if ch.is_whitespace() {
start = true;
}
start = !is_word_char(ch);
}
}
out
}
}
}
/// Whether the char at a position preceded by `prev` starts a word (for title case).
pub fn starts_word(prev: Option<char>) -> bool {
prev.is_none_or(|c| !is_word_char(c))
}
}
/// Letters, digits and apostrophes continue a word (so "don't" stays "Don't").
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '\'' | '\u{2019}')
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@ -254,6 +268,11 @@ pub struct TextData {
pub features: std::collections::BTreeMap<String, bool>,
}
/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars).
pub fn valid_feature_tag(tag: &str) -> bool {
tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b))
}
impl TextData {
pub fn new(characters: impl Into<String>) -> Self {
Self { characters: characters.into(), ..Default::default() }
@ -377,3 +396,19 @@ impl TextData {
self.runs.is_empty()
}
}
#[cfg(test)]
mod case_tests {
use super::*;
#[test]
fn title_case_respects_context() {
assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray");
// Continuing a word from a previous run: no capital.
assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World");
assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo");
assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS");
assert!(valid_feature_tag("liga") && valid_feature_tag("ss01"));
assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}"));
}
}

View File

@ -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",

View File

@ -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()),
})
}
),
]
}

View File

@ -76,9 +76,18 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
Ok(())
}
/// Type `text` replacing the selection.
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
/// characters dropped, so pasted plain text from any platform lands cleanly.
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let text: String = text
.replace("\r\n", "\n")
.replace(['\r', '\u{2028}', '\u{2029}'], "\n")
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.collect();
if text.is_empty() {
return Ok(());
}
edit(s, |t, te| {
let (a, b) = range(te);
t.replace_range(a, b, &text);
@ -161,10 +170,16 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
figcraft_text::layout_node(node).map(|(l, _)| l)
};
let (a, b) = range(&te);
let cur = te.caret;
// Without Shift, a selection collapses first: plain arrows stop at its edge, word/line moves
// continue from that edge.
let cur = match key {
"Left" | "Up" | "Home" if !shift && a != b => a,
"Right" | "Down" | "End" if !shift && a != b => b,
_ => te.caret,
};
let next = match key {
"Left" if !shift && a != b => a,
"Right" if !shift && a != b => b,
"Left" if !shift && a != b && !word && !line => a,
"Right" if !shift && a != b && !word && !line => b,
"Left" if line => line_start(&chars, cur),
"Right" if line => line_end(&chars, cur),
"Left" if word => word_left(&chars, cur),
@ -235,6 +250,11 @@ 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;
@ -251,12 +271,28 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
let mut a = index.min(chars.len());
let mut b = a;
while a > 0 && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
// Double-clicking just after a word's last char (or at the end) selects that word.
if a > 0 && chars.get(a).is_none_or(|c| !is_word(*c)) && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
a -= 1;
}
while b < chars.len() && chars.get(b).is_some_and(|c| is_word(*c)) {
b += 1;
let mut b = a;
let class = |c: char| {
if is_word(c) {
0
} else if c.is_whitespace() {
1
} else {
2
}
};
// A word, a run of spaces or a run of punctuation, like the incumbent; newlines stay unselected.
if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|c| class(*c)) {
while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n' && class(*c) == k) {
a -= 1;
}
while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n' && class(*c) == k) {
b += 1;
}
}
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
@ -267,9 +303,280 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
Ok(())
}
/// Select the line (paragraph) containing `index` (triple-click).
pub fn select_line(s: &mut Session, index: usize) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
let chars: Vec<char> = 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(())
}
/// The selected text while editing (for copy).
pub fn selected_text(s: &Session) -> Option<String> {
let (id, a, b) = selected_range(s)?;
let doc = s.doc().ok()?;
let t = doc.node(id)?.kind.text()?;
Some(t.characters.chars().skip(a).take(b.saturating_sub(a)).collect())
}
/// The selected range while editing (for styling commands), or None.
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
let te = s.ds().ok()?.text_edit.clone()?;
let (a, b) = range(&te);
Some((te.id, a, b))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration};
use serde_json::json;
fn setup(text: &str) -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap();
let id = crate::params::id_of(&r["id"]).unwrap();
(s, id)
}
fn data(s: &Session, id: NodeId) -> TextData {
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone()
}
fn sel(s: &Session) -> (usize, usize) {
let te = s.ds().unwrap().text_edit.clone().unwrap();
(te.anchor, te.caret)
}
#[test]
fn whole_editing_session_is_one_undo_step() {
let (mut s, id) = setup("Hello");
let undo_before = s.ds().unwrap().history.undo.len();
begin(&mut s, id, Some(5), false).unwrap();
insert(&mut s, " big").unwrap();
insert(&mut s, " world").unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
// Styling a range mid-session joins the same step.
set_caret(&mut s, 0, false).unwrap();
set_caret(&mut s, 5, true).unwrap();
s.execute("text.bold", &json!({})).unwrap();
s.execute("text.alignCenter", &json!({})).unwrap();
end(&mut s).unwrap();
let t = data(&s, id);
assert_eq!(t.characters, "Hello big worl");
assert_eq!(t.style_at(0).font_style, "Bold");
assert_eq!(t.style_at(6).font_style, "Regular");
assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1);
s.undo().unwrap();
let t = data(&s, id);
assert_eq!(t.characters, "Hello");
assert!(t.runs.is_empty());
assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left);
}
#[test]
fn typing_replaces_selection_and_paste_is_plain() {
let (mut s, id) = setup("Hello world");
begin(&mut s, id, None, false).unwrap();
assert_eq!(sel(&s), (0, 11), "begin without caret selects all");
select_word(&mut s, 7).unwrap();
assert_eq!(sel(&s), (6, 11));
insert(&mut s, "there").unwrap();
assert_eq!(data(&s, id).characters, "Hello there");
assert_eq!(sel(&s), (11, 11));
// Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped.
insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap();
assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td");
insert(&mut s, "\u{1}").unwrap();
assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td");
// Select the line (paragraph) then type over it.
select_line(&mut s, 0).unwrap();
assert_eq!(sel(&s), (0, 11));
insert(&mut s, "Hi").unwrap();
assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td");
assert_eq!(selected_text(&s).as_deref(), Some(""));
end(&mut s).unwrap();
}
#[test]
fn word_selection_classes() {
let (mut s, id) = setup("foo, bar_baz!\nnext");
begin(&mut s, id, Some(0), false).unwrap();
select_word(&mut s, 1).unwrap();
assert_eq!(sel(&s), (0, 3));
// Right after a word's last char selects the word.
select_word(&mut s, 13).unwrap();
assert_eq!(sel(&s), (6, 13));
// A run of spaces.
select_word(&mut s, 4).unwrap();
assert_eq!(sel(&s), (4, 6));
select_line(&mut s, 16).unwrap();
assert_eq!(sel(&s), (15, 19));
select_line(&mut s, 999).unwrap();
assert_eq!(sel(&s), (15, 19));
select_word(&mut s, 999).unwrap();
assert_eq!(sel(&s), (15, 19));
set_caret(&mut s, 999, false).unwrap();
assert_eq!(sel(&s), (19, 19), "carets clamp to the text");
}
#[test]
fn keyboard_navigation_and_extension() {
let (mut s, id) = setup("one two\nthree");
begin(&mut s, id, Some(0), false).unwrap();
key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word
assert_eq!(sel(&s), (3, 3));
key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends
assert_eq!(sel(&s), (3, 7));
key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start
assert_eq!(sel(&s), (3, 3));
key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end
assert_eq!(sel(&s), (7, 7));
key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end
assert_eq!(sel(&s), (7, 13));
key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start
assert_eq!(sel(&s), (0, 0));
key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward
assert_eq!(data(&s, id).characters, " two\nthree");
key(&mut s, "SelectAll", false, false, false).unwrap();
assert_eq!(sel(&s), (0, 10));
end(&mut s).unwrap();
}
fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
e.shift = shift;
e
}
#[test]
fn pointer_double_click_drag_and_shift_click() {
let (mut s, id) = setup("Hello world");
let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return };
if l.glyphs.is_empty() {
return; // no fonts
}
let y = l.lines[0].top + l.lines[0].height / 2.0;
let x_of = |i: usize| l.caret(i).0;
// Double-click inside "world" edits and selects the word.
let mid = (x_of(7) + x_of(8)) / 2.0;
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
assert_eq!(sel(&s), (6, 11));
// Drag-select from the start of "Hello" to the middle.
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
assert_eq!(sel(&s), (0, 0));
s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap();
s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap();
assert_eq!(sel(&s), (0, 3));
// Shift+click extends.
s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap();
s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap();
assert_eq!(sel(&s), (0, 9));
insert(&mut s, "J").unwrap();
assert_eq!(data(&s, id).characters, "Jld");
}
#[test]
fn text_commands_apply_to_edited_range() {
let (mut s, id) = setup("Hello world");
begin(&mut s, id, Some(0), false).unwrap();
set_caret(&mut s, 6, true).unwrap(); // "Hello "
for (cmd, p) in [
("text.underline", json!({})),
("text.italic", json!({})),
("text.sizeUp", json!({})),
("text.trackingUp", json!({})),
("text.lineHeightUp", json!({})),
("text.caseSmallCaps", json!({})),
("text.link", json!({"url": "https://example.com"})),
] {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
let t = data(&s, id);
let (a, b) = (t.style_at(0), t.style_at(8));
assert_eq!(a.decoration, TextDecoration::Underline);
assert!(a.italic() && !b.italic());
assert_eq!(a.font_size, 25.0);
assert_eq!(b.font_size, 24.0);
assert_eq!(a.case, TextCase::SmallCaps);
assert_eq!(a.link.as_deref(), Some("https://example.com"));
assert!(b.link.is_none());
// text.style with an explicit range (what the panel sends).
s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap();
let t = data(&s, id);
assert_eq!(t.style_at(8).font_size, 40.0);
assert_eq!(t.style_at(8).case, TextCase::Upper);
// Paragraph commands hit the edited node.
s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap();
assert_eq!(data(&s, id).list, ListStyle::Numbered);
// Every Text-menu command runs while editing.
for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) {
let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) };
if crate::find_command(item.id).is_some() {
s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id));
}
}
end(&mut s).unwrap();
}
#[test]
fn paragraph_and_feature_commands_on_nodes() {
let (mut s, id) = setup("Hello");
let ids = json!([id]);
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap();
assert_eq!(data(&s, id).features.get("liga"), Some(&false));
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap();
let f = data(&s, id).features;
assert_eq!(f.get("liga"), None);
assert_eq!(f.get("ss01"), Some(&true));
assert_eq!(f.get("tnum"), Some(&true));
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap();
assert_eq!(data(&s, id).features.len(), 1);
assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err());
assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err());
assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err());
s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap();
assert_eq!(data(&s, id).features.len(), 1);
s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap();
s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap();
s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap();
s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap();
s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap();
s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap();
let t = data(&s, id);
assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2));
assert_eq!(t.auto_resize, TextAutoResize::Truncate);
assert_eq!(t.style.decoration, TextDecoration::Strikethrough);
assert_eq!(t.style.case, TextCase::SmallCaps);
assert_eq!(t.list, ListStyle::Bulleted);
assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err());
assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err());
s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap();
assert_eq!(data(&s, id).paragraph_indent, 4.0);
}
#[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])];
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");
begin(&mut s, id, Some(2), false).unwrap();
set_caret(&mut s, 5, true).unwrap();
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
}

View File

@ -57,6 +57,8 @@ pub struct Face {
pub underline_thickness: f64,
pub strike_pos: f64,
pub shaper: harfrust::ShaperData,
/// OpenType GSUB feature tags the face provides (e.g. `smcp`).
gsub_features: Vec<[u8; 4]>,
charmap: HashMap<u32, u32>,
outlines: Mutex<HashMap<u32, Arc<BezPath>>>,
advances: Mutex<HashMap<u32, f64>>,
@ -82,6 +84,14 @@ impl Face {
let upem = f64::from(m.units_per_em.max(1));
let ascent = f64::from(m.ascent);
let descent = -f64::from(m.descent);
let mut gsub_features: Vec<[u8; 4]> = f
.gsub()
.ok()
.and_then(|g| g.feature_list().ok())
.map(|l| l.feature_records().iter().map(|r| r.feature_tag().to_be_bytes()).collect())
.unwrap_or_default();
gsub_features.sort_unstable();
gsub_features.dedup();
let charmap: HashMap<u32, u32> = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
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);
@ -106,6 +116,7 @@ impl Face {
underline_thickness: under.1.max(upem * 0.02),
strike_pos: strike,
shaper,
gsub_features,
charmap,
bytes,
outlines: Mutex::new(HashMap::new()),
@ -113,6 +124,16 @@ impl Face {
})
}
/// Whether the face's GSUB table has feature `tag` (e.g. `b"smcp"`).
pub fn has_feature(&self, tag: &[u8; 4]) -> bool {
self.gsub_features.binary_search(tag).is_ok()
}
/// The face's GSUB feature tags.
pub fn feature_tags(&self) -> Vec<String> {
self.gsub_features.iter().map(|t| String::from_utf8_lossy(t).into_owned()).collect()
}
pub fn has_char(&self, c: char) -> bool {
self.charmap.get(&(c as u32)).is_some_and(|g| *g != 0)
}

View File

@ -2,9 +2,9 @@
use std::sync::Arc;
use figcraft_doc::{LineHeight, ListStyle, TextAlignH, TextAutoResize, TextData, TextDecoration, TextStyle};
use figcraft_doc::{LineHeight, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag};
use figcraft_geom::{Affine, BezPath, Rect};
use harfrust::{Direction, ShapeOptions, UnicodeBuffer};
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
use crate::fontdb::{Face, db};
@ -78,6 +78,8 @@ fn natural_ratio(face: &Face) -> f64 {
struct Item {
face: Arc<Face>,
/// Glyph scale relative to the style's size (synthesised small caps).
scale: f64,
gid: u32,
adv: f64,
dx: f64,
@ -126,6 +128,14 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
drop(dbw);
// OpenType features the user toggled (global for the node).
let features: Vec<Feature> = text
.features
.iter()
.filter(|(k, _)| valid_feature_tag(k))
.filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..)))
.collect();
// Shape each span, split by paragraph, falling back per char.
let mut items: Vec<Item> = Vec::with_capacity(chars.len());
for (si, (start, end, _)) in spans.iter().enumerate() {
@ -152,6 +162,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if first == '\n' {
items.push(Item {
face: face.clone(),
scale: 1.0,
gid: 0,
adv: 0.0,
dx: 0.0,
@ -162,18 +173,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
ch: '\n',
});
} else {
shape_segment(&chars, seg_start, seg_end, &face, style, size, spacing, st_idx, &mut items);
let seg = Segment { start: seg_start, end: seg_end, style, size, spacing, st_idx };
shape_segment(&chars, &seg, &face, &features, &mut items);
}
seg_start = seg_end;
}
}
// List markers.
let marker_width = match text.list {
ListStyle::None => 0.0,
_ => styles.first().map(|s| sanitize_size(s.font_size) * 1.5).unwrap_or(18.0),
};
let indent = text.paragraph_indent.max(0.0);
// List markers sit in a gutter and wrapped lines hang at the text start; paragraph indent
// applies to plain paragraphs only.
let marker_width = list_gutter(text.list, &chars, styles.first(), faces.first().cloned().flatten().as_ref());
let indent = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
// Line breaking.
let wrap = match text.auto_resize {
@ -282,11 +292,43 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent - width) / gaps as f64).max(0.0),
_ => 0.0,
};
// List marker (before the line's text; zero-length char range so carets skip it).
if para_start_line && text.list != ListStyle::None {
let marker = match text.list {
ListStyle::Bulleted => "•".to_string(),
_ => format!("{}.", count_paragraphs_before(&chars, items.get(*s).map(|it| it.char_start).unwrap_or(0)) + 1),
};
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 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 };
for c in marker.chars() {
let gid = face.glyph_for(c);
let adv = face.advance(gid) * k;
out.glyphs.push(Glyph {
face: face.clone(),
gid,
x: mx,
y: baseline,
size,
advance: adv,
char_start: at,
char_end: at,
style: st_idx,
});
mx += adv;
}
}
}
for it in items.get(*s..*e).unwrap_or(&[]) {
if it.ch == '\n' {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0);
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
if it.gid != 0 || !it.is_space() {
out.glyphs.push(Glyph {
face: it.face.clone(),
@ -305,35 +347,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
pen += justify_extra;
}
}
// List marker.
if para_start_line && text.list != ListStyle::None {
let marker = match text.list {
ListStyle::Bulleted => "•".to_string(),
_ => format!("{}.", count_paragraphs_before(&chars, items.get(*s).map(|it| it.char_start).unwrap_or(0)) + 1),
};
let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0);
if let (Some(st), Some(face)) = (styles.get(st_idx), items.get(*s).map(|it| it.face.clone()).or_else(|| default_face.clone())) {
let size = sanitize_size(st.font_size);
let k = size / face.upem;
let mut mx = indent;
for c in marker.chars() {
let gid = face.glyph_for(c);
let adv = face.advance(gid) * k;
out.glyphs.push(Glyph {
face: face.clone(),
gid,
x: mx,
y: baseline,
size,
advance: adv,
char_start: 0,
char_end: 0,
style: st_idx,
});
mx += adv;
}
}
}
let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len());
let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start);
out.lines.push(Line {
@ -402,9 +415,14 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut j = 0;
while j < gl.len() {
let Some(g0) = gl.get(j) else { break };
// List markers and the ellipsis are not decorated.
if g0.char_end == g0.char_start {
j += 1;
continue;
}
let st = out.styles.get(g0.style).cloned().unwrap_or_default();
let mut k = j + 1;
while gl.get(k).is_some_and(|g| g.style == g0.style) {
while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {
k += 1;
}
if st.decoration != TextDecoration::None
@ -429,6 +447,25 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
out
}
/// Width of the list marker gutter: room for the widest marker plus a gap.
fn list_gutter(list: ListStyle, chars: &[char], style: Option<&TextStyle>, face: Option<&Arc<Face>>) -> f64 {
let size = style.map(|s| sanitize_size(s.font_size)).unwrap_or(12.0);
match list {
ListStyle::None => 0.0,
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);
(widest + size * 0.5).max(size * 1.5)
}
}
}
fn marker_advance(face: &Face, s: &str, size: f64) -> f64 {
let k = size / face.upem;
s.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum()
}
fn count_paragraphs_before(chars: &[char], pos: usize) -> usize {
chars.iter().take(pos).filter(|c| **c == '\n').count()
}
@ -442,10 +479,13 @@ fn add_ellipsis(out: &mut TextLayout, wrap: Option<f64>) {
let k = last.size / face.upem;
let adv = face.advance(gid) * k;
let limit = wrap.unwrap_or(f64::INFINITY);
// Drop glyphs until the ellipsis fits.
// 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) {
out.glyphs.pop();
}
while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.char_end > g.char_start && g.gid == g.face.glyph_for(' ')) {
out.glyphs.pop();
}
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,
@ -468,48 +508,70 @@ fn sanitize_size(s: f64) -> f64 {
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
}
#[allow(clippy::too_many_arguments)]
fn shape_segment(
chars: &[char],
/// One run of chars shaped with a single face and style.
struct Segment<'a> {
start: usize,
end: usize,
face: &Arc<Face>,
style: &TextStyle,
style: &'a TextStyle,
size: f64,
spacing: f64,
st_idx: usize,
items: &mut Vec<Item>,
) {
let k = size / face.upem;
let seg: String = chars.get(start..end).unwrap_or(&[]).iter().collect();
let cased = style.case.apply(&seg);
}
/// Synthesised small caps: lower-case letters drawn as capitals at this fraction of the size.
const SMALL_CAP_SCALE: f64 = 0.72;
fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Feature], items: &mut Vec<Item>) {
let (start, end, style) = (seg.start, seg.end, seg.style);
let k = seg.size / face.upem;
let small_caps = style.case == TextCase::SmallCaps;
let native_smcp = small_caps && face.has_feature(b"smcp");
// Map each source char through the case (title case looks at the char before the segment, so
// a word split across styled runs is capitalised once) and remember synthesised small caps.
let mut mapped: Vec<(char, usize, bool)> = Vec::with_capacity(end.saturating_sub(start));
let mut prev = start.checked_sub(1).and_then(|i| chars.get(i)).copied();
for (i, c) in chars.get(start..end).unwrap_or(&[]).iter().enumerate() {
let src = start + i;
if small_caps && !native_smcp && c.is_lowercase() {
for u in c.to_uppercase() {
mapped.push((u, src, true));
}
} else {
let mut buf = [0u8; 4];
for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() {
mapped.push((u, src, false));
}
}
prev = Some(*c);
}
let mut small = vec![false; end.saturating_sub(start)];
for (_, src, sm) in &mapped {
if *sm && let Some(f) = small.get_mut(src - start) {
*f = true;
}
}
let small_at = |src: usize| src.checked_sub(start).and_then(|i| small.get(i)).copied().unwrap_or(false);
let mut feats: Vec<Feature> = features.to_vec();
if native_smcp {
feats.push(Feature::new(Tag::new(b"smcp"), 1, ..));
}
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 mut buf = UnicodeBuffer::new();
if cased.chars().count() == seg.chars().count() {
for (i, c) in cased.chars().enumerate() {
buf.add(c, (start + i) as u32);
}
} else {
// Case mapping changed the length: map every produced char to its source char.
for (i, c) in seg.chars().enumerate() {
let mapped = style.case.apply(&c.to_string());
for u in mapped.chars() {
buf.add(u, (start + i) as u32);
}
}
for (u, src, _) in &mapped {
buf.add(*u, *src as u32);
}
buf.set_direction(Direction::LeftToRight);
buf.guess_segment_properties();
let gb = shaper.shape(buf, ShapeOptions::new());
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
}
} else {
for (i, c) in seg.chars().enumerate() {
let gid = face.glyph_for(c);
raw.push((gid, (start + i) as u32, face.advance(gid) as i32, 0, 0));
for (u, src, _) in &mapped {
let gid = face.glyph_for(*u);
raw.push((gid, *src as u32, face.advance(gid) as i32, 0, 0));
}
}
let n = raw.len();
@ -520,16 +582,18 @@ fn shape_segment(
let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1);
let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl);
let ch = chars.get(cl).copied().unwrap_or(' ');
let adv = f64::from(xa) * k + if last_in_cluster { spacing * (cend - cl) as f64 } else { 0.0 };
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 };
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(),
scale,
gid,
adv,
dx: f64::from(xo) * k,
dy: f64::from(yo) * k,
dx: f64::from(xo) * k * scale,
dy: f64::from(yo) * k * scale,
char_start: cl,
char_end: cend,
style: st_idx,
style: seg.st_idx,
ch,
});
}

View File

@ -102,3 +102,158 @@ fn glyph_paths_have_area() {
let p = l.to_path();
assert!(p.area().abs() > 1.0);
}
fn inter() -> Option<std::sync::Arc<Face>> {
if !have_fonts() {
return None;
}
db().write().unwrap().find("Inter", "Regular").filter(|f| f.family == "Inter")
}
#[test]
fn opentype_features_change_shaping() {
let Some(face) = inter() else {
eprintln!("Inter not embedded; skipping");
return;
};
let mut t = TextData::new("1111 0");
t.style.font_size = 20.0;
let base = layout(&t, None, None);
if face.has_feature(b"tnum") {
t.features.insert("tnum".into(), true);
let tab = layout(&t, None, None);
assert!((tab.width - base.width).abs() > 0.01, "tnum should change digit widths");
t.features.clear();
}
if face.has_feature(b"zero") {
t.features.insert("zero".into(), true);
let z = layout(&t, None, None);
let zero_gid = |l: &TextLayout| l.glyphs.iter().find(|g| g.char_start == 5).map(|g| g.gid);
assert_ne!(zero_gid(&z), zero_gid(&base), "zero should pick the slashed zero");
}
// Invalid tags are ignored, never a crash.
t.features.insert("bogus-tag".into(), true);
t.features.insert("".into(), false);
let _ = layout(&t, None, None);
}
#[test]
fn small_caps_native_or_synthesised() {
let Some(face) = inter() else { return };
let mut t = TextData::new("Ab");
t.style.font_size = 20.0;
let orig = layout(&t, None, None);
t.style.case = figcraft_doc::TextCase::SmallCaps;
let sc = layout(&t, None, None);
let b = |l: &TextLayout| l.glyphs.iter().find(|g| g.char_start == 1).cloned().unwrap();
let (ob, sb) = (b(&orig), b(&sc));
assert_ne!(ob.gid, sb.gid, "the lower-case b becomes a small capital");
if !face.has_feature(b"smcp") {
// Synthesised: a capital B drawn smaller than the A.
assert_eq!(sb.gid, face.glyph_for('B'));
assert!(sb.size < 20.0);
}
// Upper-case letters are untouched.
assert_eq!(sc.glyphs.iter().find(|g| g.char_start == 0).unwrap().gid, face.glyph_for('A'));
}
#[test]
fn title_case_across_runs() {
let Some(face) = inter() else { return };
let mut t = TextData::new("hello world");
t.style.case = figcraft_doc::TextCase::Title;
// "hel" bold, "lo world" regular: the word continues across the run boundary.
t.restyle(0, 3, |s| s.font_style = "Bold".into());
let l = layout(&t, None, None);
let gid_at = |i: usize| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid);
assert_eq!(gid_at(3), Some(face.glyph_for('l')), "mid-word char after a run boundary stays lower-case");
assert_eq!(gid_at(6), Some(face.glyph_for('W')));
}
#[test]
fn lists_hang_and_number() {
if !have_fonts() {
return;
}
let mut t = TextData::new("one two three four five six seven\nb");
t.auto_resize = figcraft_doc::TextAutoResize::Height;
t.list = figcraft_doc::ListStyle::Bulleted;
t.paragraph_indent = 30.0;
let l = layout(&t, Some(90.0), None);
assert!(l.lines.len() >= 3);
let first_text = |li: usize| {
let ln = &l.lines[li];
l.glyphs[ln.glyph_start..ln.glyph_end].iter().find(|g| g.char_end > g.char_start).map(|g| g.x).unwrap()
};
// Wrapped lines hang at the same x as the first line's text (paragraph indent is ignored in lists).
assert!((first_text(0) - first_text(1)).abs() < 0.01);
assert!(first_text(0) > 0.0);
// The bullet sits left of the text, and carets never land on it.
let ln = &l.lines[0];
let marker = l.glyphs[ln.glyph_start..ln.glyph_end].iter().find(|g| g.char_end == g.char_start).unwrap();
assert!(marker.x < first_text(0));
assert!(l.caret(0).0 >= first_text(0) - 0.01);
// Numbered: ten paragraphs, the text column clears "10.".
let mut n = TextData::new((1..=10).map(|i| format!("p{i}")).collect::<Vec<_>>().join("\n"));
n.list = figcraft_doc::ListStyle::Numbered;
let l = layout(&n, None, None);
assert_eq!(l.lines.len(), 10);
let last = &l.lines[9];
let gl = &l.glyphs[last.glyph_start..last.glyph_end];
let marker_end = gl.iter().filter(|g| g.char_end == g.char_start).map(|g| g.x + g.advance).fold(0.0, f64::max);
let text_x = gl.iter().find(|g| g.char_end > g.char_start).unwrap().x;
assert!(marker_end < text_x, "{marker_end} < {text_x}");
}
#[test]
fn paragraph_indent_first_line_only() {
if !have_fonts() {
return;
}
let mut t = TextData::new("aaa bbb ccc ddd eee fff ggg");
t.auto_resize = figcraft_doc::TextAutoResize::Height;
t.paragraph_indent = 20.0;
let l = layout(&t, Some(70.0), None);
assert!(l.lines.len() >= 2);
let x0 = |li: usize| l.glyphs[l.lines[li].glyph_start].x;
assert!((x0(0) - 20.0).abs() < 0.5, "{}", x0(0));
assert!(x0(1) < 1.0);
}
#[test]
fn truncate_max_lines_with_ellipsis() {
let Some(face) = inter() else { return };
let mut t = TextData::new("first line\nsecond line\nthird line");
t.auto_resize = figcraft_doc::TextAutoResize::Truncate;
t.max_lines = 2;
let l = layout(&t, Some(300.0), Some(1000.0));
assert!(l.truncated);
assert_eq!(l.lines.len(), 2);
let last = l.glyphs.last().unwrap();
assert_eq!(last.gid, face.glyph_for('…'));
// max_lines is ignored unless truncating.
t.auto_resize = figcraft_doc::TextAutoResize::None;
assert_eq!(layout(&t, Some(300.0), Some(1000.0)).lines.len(), 3);
}
#[test]
fn vertical_alignment_in_fixed_box() {
if !have_fonts() {
return;
}
let mut t = TextData::new("Hi");
t.auto_resize = figcraft_doc::TextAutoResize::None;
t.align_v = figcraft_doc::TextAlignV::Bottom;
let mut n = figcraft_doc::Node::new(figcraft_doc::NodeId(1), figcraft_doc::NodeKind::Text(t.clone()), "t", 100.0, 100.0);
let (l, dy) = layout_node(&n).unwrap();
assert!((dy + l.height - 100.0).abs() < 1e-6);
t.align_v = figcraft_doc::TextAlignV::Middle;
n.kind = figcraft_doc::NodeKind::Text(t.clone());
let (l, dy) = layout_node(&n).unwrap();
assert!((dy - (100.0 - l.height) / 2.0).abs() < 1e-6);
// Auto height ignores vertical alignment.
t.auto_resize = figcraft_doc::TextAutoResize::Height;
n.kind = figcraft_doc::NodeKind::Text(t);
assert_eq!(layout_node(&n).unwrap().1, 0.0);
}

View File

@ -193,27 +193,28 @@ fn enter_text(app: &mut FigApp) -> bool {
}
}
fn text_keys(app: &mut FigApp, _ctx: &Context, events: &[Event]) {
fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) {
// A key that ran a shortcut swallows the text it also produced (Alt+. types "≥" on macOS).
let mut skip_text = false;
for ev in events {
match ev {
Event::Text(t) => {
Event::Text(t) | Event::Ime(egui::ImeEvent::Commit(t)) => {
if skip_text && matches!(ev, Event::Text(_)) {
skip_text = false;
continue;
}
if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) {
app.toast(e.to_string());
}
}
Event::Paste(t) => {
let _ = figcraft_engine::textedit::insert(&mut app.session, 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) {
app.toast(e.to_string());
}
}
Event::Copy | Event::Cut => {
if let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session)
&& let Some(text) = app
.session
.doc()
.ok()
.and_then(|d| d.node(id))
.and_then(|n| n.kind.text())
.map(|t| t.characters.chars().skip(a).take(b - a).collect::<String>())
{
if let Some(text) = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()) {
app.services.clipboard_set_text(&text);
if matches!(ev, Event::Cut) {
let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false);
@ -221,6 +222,10 @@ fn text_keys(app: &mut FigApp, _ctx: &Context, events: &[Event]) {
}
}
Event::Key { key, pressed: true, modifiers: m, .. } => {
if (m.command || m.alt) && text_shortcut(app, *key, *m) {
skip_text = true;
continue;
}
let name = match key {
Key::Backspace => "Backspace",
Key::Delete => "Delete",
@ -234,18 +239,6 @@ fn text_keys(app: &mut FigApp, _ctx: &Context, events: &[Event]) {
Key::Escape => "Escape",
Key::Tab => "Tab",
Key::A if m.command => "SelectAll",
Key::B if m.command => {
app.run("text.bold", json!({}));
continue;
}
Key::I if m.command => {
app.run("text.italic", json!({}));
continue;
}
Key::U if m.command => {
app.run("text.underline", json!({}));
continue;
}
Key::Z if m.command => {
let _ = figcraft_engine::textedit::end(&mut app.session);
app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
@ -264,6 +257,59 @@ fn text_keys(app: &mut FigApp, _ctx: &Context, events: &[Event]) {
_ => {}
}
}
// Triple-click on the edited text selects its line (double-click is handled by the engine).
if ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary))
&& let Some(pos) = ctx.input(|i| i.pointer.interact_pos())
&& app.canvas.rect.contains(pos)
&& let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone())
{
let p = app.to_page(pos);
let inside = app.session.doc().ok().and_then(|d| d.world_bounds(te.id)).is_some_and(|b| b.contains(p));
if inside && let Ok(idx) = app.session.text_index_at(te.id, p) {
app.run("text.edit.selectLine", json!({"index": idx}));
}
}
ime_output(app, ctx);
}
/// Text-menu shortcuts (Bold, lists, alignment, size/weight/spacing steps…) while editing text.
fn text_shortcut(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
for item in MENU.iter().filter(|i| i.id.starts_with("text.") && !i.shortcut.is_empty()) {
if let Some((want, k)) = parse(item.shortcut)
&& k == key
&& mods_match(want, m)
{
crate::menus::invoke(app, item.id);
return true;
}
}
false
}
/// Tell the platform where the caret is 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 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 (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);
let (a, b) = (app.to_screen(p0.x, p0.y), app.to_screen(p1.x, p1.y));
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return;
}
let cursor_rect = egui::Rect::from_two_pos(a, b + egui::vec2(1.0, 0.0));
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput {
purpose: egui::IMEPurpose::Normal,
rect: app.canvas.rect,
cursor_rect,
should_interrupt_composition: false,
})
});
}
#[cfg(test)]

View File

@ -1,32 +1,90 @@
//! Typography section of the Design panel.
#![allow(unused_imports)]
//! 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".
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::FigApp;
use crate::icons::{self, Icon};
use crate::right::{common, menu_item, row};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD};
/// Font sizes offered by the size dropdown.
const SIZES: [f64; 15] = [10.0, 11.0, 12.0, 13.0, 14.0, 16.0, 20.0, 24.0, 32.0, 36.0, 40.0, 48.0, 64.0, 96.0, 128.0];
/// OpenType features offered in Type settings ▸ Details: (tag, label, on by default in fonts).
const FEATURES: [(&str, &str, bool); 12] = [
("liga", "Standard ligatures", true),
("calt", "Contextual alternates", true),
("tnum", "Tabular numbers", false),
("frac", "Fractions", false),
("case", "Case-sensitive forms", false),
("zero", "Slashed zero", false),
("ss01", "Stylistic set 1", false),
("ss02", "Stylistic set 2", false),
("ss03", "Stylistic set 3", false),
("ss04", "Stylistic set 4", false),
("ss05", "Stylistic set 5", false),
("smcp", "Small capitals", false),
];
const MIXED: &str = "Mixed";
/// The value shared by every style, or None when they differ.
fn uniform<T: PartialEq + Clone>(styles: &[TextStyle], f: impl Fn(&TextStyle) -> T) -> Option<T> {
let mut it = styles.iter();
let first = f(it.next()?);
if it.all(|s| f(s) == first) { Some(first) } else { None }
}
/// The character styles the panel shows: the edited range's spans, else every span of every
/// selected text layer.
fn target_styles(doc: &Document, ids: &[NodeId], range: Option<(NodeId, usize, usize)>) -> Vec<TextStyle> {
let mut out: Vec<TextStyle> = Vec::new();
let mut push = |s: &TextStyle| {
if !out.contains(s) {
out.push(s.clone());
}
};
for id in ids {
let Some(t) = doc.node(*id).and_then(|n| n.kind.text()) else { continue };
match range {
Some((rid, a, b)) if rid == *id && a < b => {
for (s, e, st) in t.spans() {
if s < b && e > a {
push(st);
}
}
}
_ => {
for (_, _, st) in t.spans() {
push(st);
}
}
}
}
out
}
pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let t = Tokens::get(ui.ctx());
let ids: Vec<NodeId> = sel.iter().copied().filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect();
// While editing a range, style edits apply to it.
if ids.is_empty() {
return;
}
// While editing a range, character edits apply to it.
let range = figcraft_engine::textedit::selected_range(&app.session);
let style_of = |n: &Node| -> Option<TextStyle> {
let td = n.kind.text()?;
match range {
Some((id, a, b)) if id == n.id && a != b => Some(td.style_at(a).clone()),
_ => Some(td.style.clone()),
}
};
let st = common(doc, &ids, style_of).flatten();
let td = common(doc, &ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v, t.auto_resize))).flatten();
widgets::section_header(ui, "Typography", &[(Icon::Styles, "Text styles")], false);
let editing = range.is_some();
let styles = target_styles(doc, &ids, range);
let apply_style = |app: &mut FigApp, key: &str, v: Value| {
if let Some((id, a, b)) = range
&& a != b
@ -36,64 +94,120 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
app.set_props(&ids, json!({ key: v }));
}
};
let family = st.as_ref().map(|s| s.font_family.clone()).unwrap_or_else(|| "Mixed".into());
let fstyle = st.as_ref().map(|s| s.font_style.clone()).unwrap_or_else(|| "Mixed".into());
widgets::section_header(ui, "Typography", &[(Icon::Styles, "Text styles")], false);
// Font family.
let family = uniform(&styles, |s| s.font_family.clone());
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, w, None);
egui::Popup::menu(&resp).width(260.0).show(|ui| {
let q_id = Id::new("font_query");
let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(240.0));
ui.data_mut(|d| d.insert_temp(q_id, q.clone()));
let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(MIXED), w, None);
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);
apply_style(app, "fontFamily", json!(f));
ui.close();
}
});
});
// 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 fam = family.clone();
egui::Popup::menu(&resp).width(200.0).show(|ui| match &fam {
Some(fam) => {
let list = figcraft_text::family_styles(fam);
if list.is_empty() {
widgets::label(ui, "Font not installed", t.text_tertiary);
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for s in list {
let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " };
if menu_item(ui, &format!("{mark}{s}"), "") {
apply_style(app, "fontStyle", json!(s));
ui.close();
}
}
});
}
None => widgets::label(ui, "Pick one font family first", t.text_tertiary),
});
let size = uniform(&styles, |s| s.font_size);
ui.spacing_mut().item_spacing.x = 0.0;
let e = widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half - 20.0, 1.0);
match e {
Some(FieldEdit::Set(v)) => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))),
// 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();
}
}
None => {}
}
let more = icons::button(ui, Icon::Chevron, 20.0, false, "Font size");
egui::Popup::menu(&more).width(90.0).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for f in figcraft_text::family_names().into_iter().filter(|f| q.is_empty() || f.to_lowercase().contains(&q.to_lowercase())).take(400)
{
if menu_item(ui, &f, "") {
apply_style(app, "fontFamily", json!(f));
for v in SIZES {
let mark = if size == Some(v) { "✓ " } else { " " };
if menu_item(ui, &format!("{mark}{}", trim_num(v)), "") {
apply_style(app, "fontSize", json!(v));
ui.close();
}
}
});
});
});
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
// Line height + letter spacing.
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, &fstyle, half, None);
let fam = family.clone();
egui::Popup::menu(&resp).width(200.0).show(|ui| {
for s in figcraft_text::family_styles(&fam) {
if menu_item(ui, &s, "") {
apply_style(app, "fontStyle", json!(s));
ui.close();
}
}
let lh = uniform(&styles, |s| s.line_height);
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| MIXED.into());
if let Some(v) = lead_field(ui, Id::new("ty_lh"), LeadGlyph::LineHeight, &lh_label, half, "Line height")
&& let Some(v) = parse_line_height(&v, lh)
{
apply_style(app, "lineHeight", v);
}
let ls = uniform(&styles, |s| s.letter_spacing);
let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| MIXED.into());
if let Some(v) = lead_field(ui, Id::new("ty_ls"), LeadGlyph::LetterSpacing, &ls_label, half, "Letter spacing")
&& let Some(v) = parse_letter_spacing(&v, ls)
{
apply_style(app, "letterSpacing", v);
}
});
// Paragraph spacing + resizing.
let ps = common(doc, &ids, |n| n.kind.text().map(|t| t.paragraph_spacing.to_bits())).flatten().map(f64::from_bits);
let resize = common(doc, &ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
row(ui, 32.0, |ui| {
let ps_label = ps.map(trim_num).unwrap_or_else(|| MIXED.into());
if let Some(v) = lead_field(ui, Id::new("ty_ps"), LeadGlyph::ParagraphSpacing, &ps_label, half, "Paragraph spacing")
&& let Some(v) = widgets::eval(&v, ps)
{
app.set_props(&ids, json!({"paragraphSpacing": v.clamp(0.0, 10_000.0)}));
}
let items = [(Icon::Hug, "Auto width"), (Icon::LayoutV, "Auto height"), (Icon::Fixed, "Fixed size"), (Icon::Dots, "Truncate text")];
let active = resize.map(|r| match r {
TextAutoResize::WidthAndHeight => 0,
TextAutoResize::Height => 1,
TextAutoResize::None => 2,
TextAutoResize::Truncate => 3,
});
let size = st.as_ref().map(|s| s.font_size);
let e = widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half, 1.0);
match e {
Some(FieldEdit::Set(v)) | Some(FieldEdit::Scrub(v, true)) => apply_style(app, "fontSize", json!(v.max(1.0))),
Some(FieldEdit::Scrub(v, false)) => {
let _ = app.session.begin_interaction("Font size");
let _ = app.session.execute("node.set", &json!({"ids": ids, "props": {"fontSize": v.max(1.0)}}));
}
None => {}
}
if let Some(FieldEdit::Scrub(_, true)) = e {
let _ = app.session.commit_interaction();
}
});
row(ui, 32.0, |ui| {
let lh = st.as_ref().map(|s| s.line_height);
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| "Mixed".into());
if let Some(v) = widgets::text_field(ui, Id::new("ty_lh"), &lh_label, half, "Line height") {
apply_style(app, "lineHeight", if v.trim().eq_ignore_ascii_case("auto") { json!("auto") } else { json!(v.trim()) });
}
let ls = st.as_ref().map(|s| s.letter_spacing.label()).unwrap_or_else(|| "Mixed".into());
if let Some(v) = widgets::text_field(ui, Id::new("ty_ls"), &ls, half, "Letter spacing") {
apply_style(app, "letterSpacing", json!(v.trim()));
if let Some(i) = widgets::segmented(ui, &items, active, (half / 4.0).floor()) {
let v = ["widthAndHeight", "height", "none", "truncate"].get(i).copied().unwrap_or("none");
app.set_props(&ids, json!({"textAutoResize": v}));
}
});
// Alignment + Type settings.
let align = common(doc, &ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v))).flatten();
row(ui, 32.0, |ui| {
let items = [
(Icon::TextAlignLeft, "Align left"),
@ -101,60 +215,551 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
(Icon::TextAlignRight, "Align right"),
(Icon::TextAlignJustify, "Justify"),
];
let active = td.map(|(h, _, _)| match h {
let active = align.map(|(h, _)| match h {
TextAlignH::Left => 0,
TextAlignH::Center => 1,
TextAlignH::Right => 2,
TextAlignH::Justified => 3,
});
if let Some(i) = widgets::segmented(ui, &items, active, 26.0) {
app.set_props(&ids, json!({"textAlign": (["left", "center", "right", "justified"][i])}));
if let Some(i) = widgets::segmented(ui, &items, active, 24.0) {
let v = ["left", "center", "right", "justified"].get(i).copied().unwrap_or("left");
app.set_props(&ids, json!({"textAlign": v}));
}
let items = [(Icon::TextTop, "Top"), (Icon::TextMiddle, "Middle"), (Icon::TextBottom, "Bottom")];
let active = td.map(|(_, v, _)| match v {
let items = [(Icon::TextTop, "Align top"), (Icon::TextMiddle, "Align middle"), (Icon::TextBottom, "Align bottom")];
let active = align.map(|(_, v)| match v {
TextAlignV::Top => 0,
TextAlignV::Middle => 1,
TextAlignV::Bottom => 2,
});
if let Some(i) = widgets::segmented(ui, &items, active, 26.0) {
app.set_props(&ids, json!({"textAlignVertical": (["top", "middle", "bottom"][i])}));
if let Some(i) = widgets::segmented(ui, &items, active, 24.0) {
let v = ["top", "middle", "bottom"].get(i).copied().unwrap_or("top");
app.set_props(&ids, json!({"textAlignVertical": v}));
}
let open_id = Id::new("ty_settings_open");
let open = egui::Popup::is_id_open(ui.ctx(), open_id);
let more = icons::button(ui, Icon::Dots, 24.0, open, "Type settings");
egui::Popup::from_toggle_button_response(&more)
.id(open_id)
.width(264.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| type_settings(app, ui, doc, &ids, &styles, &apply_style));
});
row(ui, 32.0, |ui| {
let items = [(Icon::Hug, "Auto width"), (Icon::LayoutV, "Auto height"), (Icon::Fixed, "Fixed size"), (Icon::Dots, "Truncate text")];
let active = td.map(|(_, _, r)| match r {
TextAutoResize::WidthAndHeight => 0,
TextAutoResize::Height => 1,
TextAutoResize::None => 2,
TextAutoResize::Truncate => 3,
});
if let Some(i) = widgets::segmented(ui, &items, active, 26.0) {
app.set_props(&ids, json!({"textAutoResize": (["widthAndHeight", "height", "none", "truncate"][i])}));
}
let deco = st.as_ref().map(|s| s.decoration);
let more = icons::button(ui, Icon::Dots, 24.0, false, "Type details");
egui::Popup::menu(&more).width(220.0).show(|ui| {
ui.label(egui::RichText::new("Decoration").font(semibold(11.0)));
for (l, v) in [("None", "none"), ("Underline", "underline"), ("Strikethrough", "strikethrough")] {
if menu_item(ui, &format!("{}{l}", if format!("{deco:?}").eq_ignore_ascii_case(&format!("Some({l})")) { "✓ " } else { "" }), "") {
apply_style(app, "textDecoration", json!(v));
}
}
ui.separator();
ui.label(egui::RichText::new("Case").font(semibold(11.0)));
for (l, v) in [("Original", "original"), ("Uppercase", "upper"), ("Lowercase", "lower"), ("Title case", "title")] {
if menu_item(ui, l, "") {
apply_style(app, "textCase", json!(v));
}
}
ui.separator();
ui.label(egui::RichText::new("List").font(semibold(11.0)));
for (l, v) in [("None", "none"), ("Bulleted", "bulleted"), ("Numbered", "numbered")] {
if menu_item(ui, l, "") {
app.set_props(&ids, json!({"listStyle": v}));
}
}
});
});
let _ = t;
}
/// The Type settings popover: Basics (decoration, case, list, indent, truncation) and Details
/// (OpenType features).
fn type_settings(
app: &mut FigApp,
ui: &mut Ui,
doc: &Document,
ids: &[NodeId],
styles: &[TextStyle],
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);
ui.horizontal(|ui| {
for (i, l) in ["Basics", "Details"].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() {
tab = i;
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.add_space(4.0);
let w = ui.available_width();
if tab == 0 {
let deco = uniform(styles, |s| s.decoration);
caption(ui, "Decoration");
let active = deco.map(|d| match d {
TextDecoration::None => 0,
TextDecoration::Underline => 1,
TextDecoration::Strikethrough => 2,
});
if let Some(i) = text_seg(ui, "deco", &[("—", "None"), ("U", "Underline"), ("S", "Strikethrough")], active, w) {
apply_style(app, "textDecoration", json!((["none", "underline", "strikethrough"].get(i).copied().unwrap_or("none"))));
}
caption(ui, "Letter case");
let case = uniform(styles, |s| s.case);
let active = case.map(|c| match c {
TextCase::Original => 0,
TextCase::Upper => 1,
TextCase::Lower => 2,
TextCase::Title => 3,
TextCase::SmallCaps => 4,
});
let items = [("—", "As typed"), ("AG", "Uppercase"), ("ag", "Lowercase"), ("Ag", "Title case"), ("Aɢ", "Small caps")];
if let Some(i) = text_seg(ui, "case", &items, active, w) {
apply_style(app, "textCase", json!((["original", "upper", "lower", "title", "smallCaps"].get(i).copied().unwrap_or("original"))));
}
caption(ui, "List style");
let list = common(doc, ids, |n| n.kind.text().map(|t| t.list)).flatten();
let active = list.map(|l| match l {
ListStyle::None => 0,
ListStyle::Bulleted => 1,
ListStyle::Numbered => 2,
});
if let Some(i) = text_seg(ui, "list", &[("—", "No list"), ("•", "Bulleted list"), ("1.", "Numbered list")], active, w) {
app.set_props(ids, json!({"listStyle": (["none", "bulleted", "numbered"].get(i).copied().unwrap_or("none"))}));
}
ui.add_space(6.0);
let indent = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_indent.to_bits())).flatten().map(f64::from_bits);
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new("Paragraph indent").font(regular(11.0)).color(t.text)));
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0)
{
app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)}));
}
});
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
caption(ui, "Truncate text");
let active = resize.map(|r| usize::from(r == TextAutoResize::Truncate));
match text_seg(ui, "trunc", &[("Off", "Don't truncate"), ("…", "Truncate with an ellipsis")], active, w) {
Some(1) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
Some(_) if resize != Some(TextAutoResize::Height) && resize != Some(TextAutoResize::WidthAndHeight) => {
app.set_props(ids, json!({"textAutoResize": "none"}))
}
_ => {}
}
let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten();
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new("Max lines").font(regular(11.0)).color(t.text)));
let shown = max.map(f64::from).filter(|m| *m > 0.0);
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
widgets::number_field(ui, Id::new("ty_maxlines"), Lead::None, shown, "", w - 118.0, 1.0)
{
app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)}));
}
});
} 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));
let face_name = face.as_ref().map(|f| format!("{} {}", f.family, f.style)).unwrap_or_else(|| "Mixed fonts".into());
widgets::label(ui, &face_name, t.text_secondary);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (tag, label, default_on) in FEATURES {
let state = common(doc, ids, |n| n.kind.text().map(|t| t.features.get(tag).copied())).flatten();
let on = state.map(|s| s.unwrap_or(default_on));
let supported = face.as_ref().is_none_or(|f| f.has_feature(tag.as_bytes().try_into().unwrap_or(b" ")));
if feature_row(ui, tag, label, on, supported) {
let want = !on.unwrap_or(false);
// Back to the font's default clears the override.
let v = if want == default_on { Value::Null } else { json!(want) };
app.run("text.setFeatures", json!({"ids": ids, "features": {tag: v}}));
}
}
});
}
}
fn caption(ui: &mut Ui, s: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(6.0);
ui.label(egui::RichText::new(s).font(regular(10.5)).color(t.text_secondary));
}
/// A segmented control with short text labels (decoration, case, list…). Returns the clicked index.
fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usize>, width: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
let cell = width / items.len().max(1) as f32;
let mut out = None;
for (i, (label, tip)) in items.iter().enumerate() {
let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H));
let resp = ui.interact(r, Id::new(("ty_seg", salt, i)), Sense::click()).on_hover_text(*tip);
if active == Some(i) {
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel);
ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.hover);
}
let col = if active == Some(i) { t.text } else { t.text_secondary };
let galley = ui.painter().layout_no_wrap((*label).to_string(), regular(11.5), col);
let gr = Rect::from_center_size(r.center(), galley.size());
let size = galley.size();
ui.painter().galley(gr.min, galley, col);
// Draw the decoration on its own sample letter.
match *tip {
"Underline" => {
let y = gr.max.y - 1.5;
ui.painter().line_segment([pos2(gr.min.x, y), pos2(gr.min.x + size.x, y)], Stroke::new(1.0, col));
}
"Strikethrough" => {
let y = gr.center().y + 0.5;
ui.painter().line_segment([pos2(gr.min.x - 1.0, y), pos2(gr.min.x + size.x + 1.0, y)], Stroke::new(1.0, col));
}
_ => {}
}
if resp.clicked() {
out = Some(i);
}
}
out
}
/// One OpenType feature toggle; `on` None = mixed. Returns true when clicked.
fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option<bool>, supported: bool) -> bool {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
let resp = if supported { resp } else { resp.on_hover_text("Not in this font") };
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
}
let b = Rect::from_min_size(pos2(rect.min.x + 4.0, rect.center().y - 7.0), vec2(14.0, 14.0));
match on {
Some(true) => {
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE);
}
Some(false) => {
ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
}
None => {
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, Color32::WHITE));
}
}
let col = if supported { t.text } else { t.text_tertiary };
ui.painter().text(pos2(b.max.x + 8.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), col);
ui.painter().text(pos2(rect.max.x - 6.0, rect.center().y), Align2::RIGHT_CENTER, tag, regular(10.0), t.text_tertiary);
resp.clicked()
}
#[derive(Clone, Copy)]
enum LeadGlyph {
LineHeight,
LetterSpacing,
ParagraphSpacing,
}
/// Paint a small original glyph for a typography field's lead.
fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32) {
let c = r.center();
let s = Stroke::new(1.0, col);
match g {
LeadGlyph::LineHeight => {
p.line_segment([pos2(c.x - 5.0, c.y - 6.0), pos2(c.x + 5.0, c.y - 6.0)], s);
p.line_segment([pos2(c.x - 5.0, c.y + 6.0), pos2(c.x + 5.0, c.y + 6.0)], s);
p.text(c, Align2::CENTER_CENTER, "A", regular(9.5), col);
}
LeadGlyph::LetterSpacing => {
p.line_segment([pos2(c.x - 6.0, c.y - 5.0), pos2(c.x - 6.0, c.y + 5.0)], s);
p.line_segment([pos2(c.x + 6.0, c.y - 5.0), pos2(c.x + 6.0, c.y + 5.0)], s);
p.text(c, Align2::CENTER_CENTER, "A", regular(9.5), col);
}
LeadGlyph::ParagraphSpacing => {
for dy in [-5.0, -2.5, 2.5, 5.0] {
p.line_segment([pos2(c.x - 5.0, c.y + dy), pos2(c.x + 5.0, c.y + dy)], s);
}
}
}
}
/// A text field with a leading glyph (line height, letter spacing…). Returns the committed text.
fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, tip: &str) -> Option<String> {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
let lead = Rect::from_min_size(rect.min, vec2(24.0, FIELD_H));
let text_rect = Rect::from_min_max(pos2(lead.max.x, rect.min.y), rect.max);
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
let resp = ui.interact(rect, id.with("click"), Sense::click()).on_hover_text(tip);
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
paint_lead(ui.painter(), lead, glyph, t.icon_secondary);
if editing.is_none() && resp.clicked() {
editing = Some(if value == MIXED { String::new() } else { value.to_string() });
ui.memory_mut(|m| m.request_focus(id.with("te")));
}
let mut out = None;
match editing {
Some(mut text) => {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(2.0, 4.0))));
let r = child.add(
egui::TextEdit::singleline(&mut text)
.id(id.with("te"))
.frame(egui::Frame::NONE)
.font(regular(11.0))
.desired_width(text_rect.width() - 6.0),
);
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
if esc {
ui.data_mut(|d| d.remove::<String>(editing_id));
} else if r.lost_focus() || enter {
out = Some(text);
ui.data_mut(|d| d.remove::<String>(editing_id));
} else {
ui.data_mut(|d| d.insert_temp(editing_id, text));
}
}
None => {
let col = if value == MIXED { t.text_tertiary } else { t.text };
ui.painter().text(pos2(text_rect.min.x + 2.0, rect.center().y), Align2::LEFT_CENTER, value, regular(11.0), col);
}
}
out
}
/// "Auto", "24", "150%" → the `lineHeight` value. A bare number keeps the current unit when it is
/// a percentage, else means pixels.
fn parse_line_height(s: &str, cur: Option<LineHeight>) -> Option<Value> {
let s = s.trim();
if s.is_empty() || s.eq_ignore_ascii_case("auto") {
return Some(json!("auto"));
}
if let Some(p) = s.strip_suffix('%') {
let cur_pct = match cur {
Some(LineHeight::Percent(p)) => Some(p),
_ => None,
};
return widgets::eval(p.trim(), cur_pct).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0)))));
}
if let Some(px) = s.strip_suffix("px") {
return widgets::eval(px.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0)));
}
match cur {
Some(LineHeight::Percent(p)) => widgets::eval(s, Some(p)).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0))))),
Some(LineHeight::Pixels(px)) => widgets::eval(s, Some(px)).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0))),
_ => widgets::eval(s, None).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0))),
}
}
/// "2%", "1.5px", "-0.5" → the `letterSpacing` value. A bare number keeps the current unit
/// (percent by default).
fn parse_letter_spacing(s: &str, cur: Option<LetterSpacing>) -> Option<Value> {
let s = s.trim();
if let Some(p) = s.strip_suffix('%') {
return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v))));
}
if let Some(p) = s.strip_suffix("px") {
return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v));
}
match cur {
Some(LetterSpacing::Pixels(px)) => widgets::eval(s, Some(px)).filter(|v| v.is_finite()).map(|v| json!(v)),
Some(LetterSpacing::Percent(p)) => widgets::eval(s, Some(p)).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))),
None => widgets::eval(s, None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))),
}
}
// ---- Font picker ----
const RECENT_KEY: &str = "figcraft_recent_fonts";
const ROW: f32 = 26.0;
fn recent_families(ctx: &egui::Context) -> Vec<String> {
ctx.data_mut(|d| d.get_persisted::<Vec<String>>(Id::new(RECENT_KEY))).unwrap_or_default()
}
fn remember_family(ctx: &egui::Context, family: &str) {
ctx.data_mut(|d| {
let v = d.get_persisted_mut_or_default::<Vec<String>>(Id::new(RECENT_KEY));
v.retain(|f| !f.eq_ignore_ascii_case(family));
v.insert(0, family.to_string());
v.truncate(8);
});
}
enum PickRow {
Header(&'static str),
Family(String),
}
/// The searchable family list. Returns the family picked.
fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option<String> {
let t = Tokens::get(ui.ctx());
let q_id = Id::new("font_query");
let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
if just_opened {
q.clear();
}
let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(ui.available_width()).font(regular(11.5)));
if just_opened {
search.request_focus();
}
ui.data_mut(|d| d.insert_temp(q_id, q.clone()));
let ql = q.trim().to_lowercase();
let all = figcraft_text::family_names();
let matches: Vec<String> = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
return matches.into_iter().next();
}
let mut rows: Vec<PickRow> = Vec::new();
if ql.is_empty() {
let recent: Vec<String> = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect();
if !recent.is_empty() {
rows.push(PickRow::Header("Recent"));
rows.extend(recent.into_iter().map(PickRow::Family));
rows.push(PickRow::Header("All fonts"));
}
}
if matches.is_empty() {
widgets::label(ui, "No fonts match", t.text_tertiary);
return None;
}
rows.extend(matches.into_iter().map(PickRow::Family));
let previews = Previews::get(ui.ctx());
let mut budget = 4;
let mut picked = None;
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| {
for r in rows.get(range).unwrap_or(&[]) {
let w = ui.available_width();
let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() });
match r {
PickRow::Header(h) => {
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary);
}
PickRow::Family(f) => {
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
}
let col = if hovered { Color32::WHITE } else { t.text };
if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) {
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)),
Icon::Check,
col,
);
}
let text_min = pos2(rect.min.x + 22.0, rect.min.y);
match previews.texture(ui.ctx(), f, &mut budget) {
Some(tex) => {
let ppp = ui.ctx().pixels_per_point().max(0.5);
let size = tex.size_vec2() / ppp;
let avail = rect.max.x - text_min.x - 6.0;
let shown = vec2(size.x.min(avail), size.y.min(ROW));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(shown.x / size.x.max(1.0), shown.y / size.y.max(1.0)));
let r = Rect::from_min_size(pos2(text_min.x, rect.center().y - shown.y / 2.0), shown);
ui.painter().image(tex.id(), r, uv, col);
}
None => {
ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col);
}
}
if resp.clicked() {
picked = Some(f.clone());
}
}
}
}
});
if budget == 0 {
ui.ctx().request_repaint();
}
picked
}
/// Family-name previews drawn in their own font by FigCraft's text engine (rendered lazily, a
/// few per frame, cached for the session). `None` = no usable preview: the name shows plain.
#[derive(Clone, Default)]
struct Previews(Arc<Mutex<HashMap<String, Option<egui::TextureHandle>>>>);
impl Previews {
fn get(ctx: &egui::Context) -> Previews {
ctx.data_mut(|d| d.get_temp_mut_or_default::<Previews>(Id::new("font_previews")).clone())
}
fn texture(&self, ctx: &egui::Context, family: &str, budget: &mut usize) -> Option<egui::TextureHandle> {
let mut map = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(t) = map.get(family) {
return t.clone();
}
if *budget == 0 {
return None;
}
*budget -= 1;
let tex = render_preview(ctx, family);
if map.len() > 2000 {
map.clear();
}
map.insert(family.to_string(), tex.clone());
tex
}
}
fn render_preview(ctx: &egui::Context, family: &str) -> Option<egui::TextureHandle> {
// Only when the family resolves to itself and can draw its own name (not symbol fonts).
let styles = figcraft_text::family_styles(family);
let style = styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first())?.clone();
let face = figcraft_text::find_face(family, &style)?;
if !face.family.eq_ignore_ascii_case(family) || !family.chars().all(|c| c == ' ' || face.has_char(c)) {
return None;
}
let mut doc = Document::new("preview");
let page = doc.first_page()?;
let id = doc.alloc_id();
let mut td = TextData::new(family);
td.style.font_family = family.to_string();
td.style.font_style = style;
td.style.font_size = 13.0;
let mut n = Node::new(id, NodeKind::Text(td), "preview", 1.0, 1.0);
n.fills = vec![Paint::solid(Color::WHITE)];
let (w, h) = figcraft_text::auto_size(&n)?;
n.width = w.min(400.0);
n.height = h.min(40.0);
doc.insert(n, page, None).ok()?;
let ppp = f64::from(ctx.pixels_per_point()).clamp(1.0, 3.0);
let pix = figcraft_render::render_node(&doc, id, ppp)?;
let (pw, ph) = (pix.width() as usize, pix.height() as usize);
let rgba = figcraft_render::to_rgba8(&pix);
if rgba.len() != pw * ph * 4 {
return None;
}
let img = egui::ColorImage::from_rgba_unmultiplied([pw, ph], &rgba);
Some(ctx.load_texture(format!("font-preview-{family}"), img, egui::TextureOptions::LINEAR))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_line_height_and_letter_spacing() {
assert_eq!(parse_line_height("Auto", None), Some(json!("auto")));
assert_eq!(parse_line_height("", Some(LineHeight::Pixels(20.0))), Some(json!("auto")));
assert_eq!(parse_line_height("150%", Some(LineHeight::Auto)), Some(json!("150%")));
assert_eq!(parse_line_height("24", Some(LineHeight::Auto)), Some(json!(24.0)));
assert_eq!(parse_line_height("24px", Some(LineHeight::Percent(120.0))), Some(json!(24.0)));
assert_eq!(parse_line_height("130", Some(LineHeight::Percent(120.0))), Some(json!("130%")));
assert_eq!(parse_line_height("-5", Some(LineHeight::Pixels(10.0))), Some(json!(0.0)));
assert_eq!(parse_line_height("abc", None), None);
assert_eq!(parse_letter_spacing("2%", None), Some(json!("2%")));
assert_eq!(parse_letter_spacing("1.5px", None), Some(json!(1.5)));
assert_eq!(parse_letter_spacing("-1", None), Some(json!("-1%")));
assert_eq!(parse_letter_spacing("3", Some(LetterSpacing::Pixels(1.0))), Some(json!(3.0)));
assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None);
}
#[test]
fn uniform_reports_mixed() {
let a = TextStyle::default();
let b = TextStyle { font_size: 30.0, ..Default::default() };
assert_eq!(uniform(&[a.clone(), b.clone()], |s| s.font_family.clone()), Some("Inter".to_string()));
assert_eq!(uniform(&[a.clone(), b], |s| s.font_size), None);
assert_eq!(uniform(&[], |s: &TextStyle| s.font_size), None);
// Ranges pick only the spans they touch.
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
let id = doc.alloc_id();
let mut td = TextData::new("Hello world");
td.restyle(6, 11, |s| s.font_size = 40.0);
doc.insert(Node::new(id, NodeKind::Text(td), "t", 10.0, 10.0), page, None).unwrap();
assert_eq!(target_styles(&doc, &[id], Some((id, 0, 5))).len(), 1);
assert_eq!(target_styles(&doc, &[id], Some((id, 4, 8))).len(), 2);
assert_eq!(target_styles(&doc, &[id], Some((id, 3, 3))).len(), 2);
assert_eq!(target_styles(&doc, &[id], None).len(), 2);
}
}