diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b92f8b2..a24368d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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) -> 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) -> 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, } +/// 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) -> 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}")); + } +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 344eed6..0974064 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -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(v: &Value) -> Option { +pub(crate) fn enum_of(v: &Value) -> Option { 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 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)>> { + 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", diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0320fe7..6171587 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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 { if let Some((id, a, b)) = textedit::selected_range(s) { @@ -154,6 +158,67 @@ pub fn specs() -> Vec { 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 { 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()), + }) + } + ), ] } diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index d67d7ee..3bec8b3 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -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 = 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 = 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 { + 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}"); + } + } + } + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d1a549f..dec9a41 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -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, outlines: Mutex>>, advances: Mutex>, @@ -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 = 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 { + 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) } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 9c0686c..4af7f08 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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, + /// 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, box_height: Option) } drop(dbw); + // OpenType features the user toggled (global for the node). + let features: Vec = 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 = 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, box_height: Option) 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, box_height: Option) 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, box_height: Option) (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, box_height: Option) 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, box_height: Option) 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, box_height: Option) 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>) -> 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) { 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, - style: &TextStyle, + style: &'a TextStyle, size: f64, spacing: f64, st_idx: usize, - items: &mut Vec, -) { - 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, features: &[Feature], items: &mut Vec) { + 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 = 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, }); } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 97caca1..021a70b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -102,3 +102,158 @@ fn glyph_paths_have_area() { let p = l.to_path(); assert!(p.area().abs() > 1.0); } + +fn inter() -> Option> { + 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::>().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); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index af127a9..541edc4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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::()) - { + 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)] diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 5f9fff9..9629857 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -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(styles: &[TextStyle], f: impl Fn(&TextStyle) -> T) -> Option { + 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 { + let mut out: Vec = 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 = 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 { - 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, width: f32) -> Option { + 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, 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 { + 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 = 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::(editing_id)); + } else if r.lost_focus() || enter { + out = Some(text); + ui.data_mut(|d| d.remove::(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) -> Option { + 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) -> Option { + 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 { + ctx.data_mut(|d| d.get_persisted::>(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::>(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 { + 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 = 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 = Vec::new(); + if ql.is_empty() { + let recent: Vec = 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>>>); + +impl Previews { + fn get(ctx: &egui::Context) -> Previews { + ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::new("font_previews")).clone()) + } + + fn texture(&self, ctx: &egui::Context, family: &str, budget: &mut usize) -> Option { + 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 { + // 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); + } }