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:
parent
23c54a6663
commit
b943e12f3d
|
|
@ -86,28 +86,42 @@ pub enum TextCase {
|
||||||
|
|
||||||
impl TextCase {
|
impl TextCase {
|
||||||
pub fn apply(self, s: &str) -> String {
|
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 {
|
match self {
|
||||||
TextCase::Original | TextCase::SmallCaps => s.to_string(),
|
TextCase::Original | TextCase::SmallCaps => s.to_string(),
|
||||||
TextCase::Upper => s.to_uppercase(),
|
TextCase::Upper => s.to_uppercase(),
|
||||||
TextCase::Lower => s.to_lowercase(),
|
TextCase::Lower => s.to_lowercase(),
|
||||||
TextCase::Title => {
|
TextCase::Title => {
|
||||||
let mut out = String::with_capacity(s.len());
|
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() {
|
for ch in s.chars() {
|
||||||
if start && ch.is_alphabetic() {
|
if start && ch.is_alphanumeric() {
|
||||||
out.extend(ch.to_uppercase());
|
out.extend(ch.to_uppercase());
|
||||||
start = false;
|
start = false;
|
||||||
} else {
|
} else {
|
||||||
out.push(ch);
|
out.push(ch);
|
||||||
if ch.is_whitespace() {
|
start = !is_word_char(ch);
|
||||||
start = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
|
@ -254,6 +268,11 @@ pub struct TextData {
|
||||||
pub features: std::collections::BTreeMap<String, bool>,
|
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 {
|
impl TextData {
|
||||||
pub fn new(characters: impl Into<String>) -> Self {
|
pub fn new(characters: impl Into<String>) -> Self {
|
||||||
Self { characters: characters.into(), ..Default::default() }
|
Self { characters: characters.into(), ..Default::default() }
|
||||||
|
|
@ -377,3 +396,19 @@ impl TextData {
|
||||||
self.runs.is_empty()
|
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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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), \
|
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, \
|
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 \
|
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}";
|
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||||
|
|
||||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
/// 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()?;
|
let s = v.as_str()?;
|
||||||
// Accept camelCase, snake/upper-case and spaced variants.
|
// Accept camelCase, snake/upper-case and spaced variants.
|
||||||
let norm: String = {
|
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),
|
"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"))?,
|
"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,
|
"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" => {}
|
"start" | "end" => {}
|
||||||
"pointCount" => {
|
"pointCount" => {
|
||||||
let c = f64_of(v).unwrap_or(5.0).clamp(3.0, 120.0) as u32;
|
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",
|
"visible" => "visible",
|
||||||
"name" => "name",
|
"name" => "name",
|
||||||
"characters" | "text" => "characters",
|
"characters" | "text" => "characters",
|
||||||
|
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" => "textStyle",
|
||||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
"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") => {
|
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);
|
t.restyle(start, end, |s| s.decoration = d);
|
||||||
}
|
}
|
||||||
"textCase" => {
|
"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);
|
t.restyle(start, end, |s| s.case = c);
|
||||||
}
|
}
|
||||||
"hyperlink" => {
|
"hyperlink" => {
|
||||||
|
|
@ -572,6 +584,24 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
|
||||||
Ok(())
|
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 {
|
pub fn weight_name(w: u16) -> &'static str {
|
||||||
match w {
|
match w {
|
||||||
0..=149 => "Thin",
|
0..=149 => "Thin",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ use super::{CommandSpec, ok};
|
||||||
use crate::params::*;
|
use crate::params::*;
|
||||||
use crate::{Result, Session, bad, spec, textedit};
|
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.
|
/// 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> {
|
fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value> {
|
||||||
if let Some((id, a, b)) = textedit::selected_range(s) {
|
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.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.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.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| {
|
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 c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||||
let ids = req_targets("text.set", s, p)?;
|
let 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))?;
|
textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?;
|
||||||
ok()
|
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, _| {
|
spec!("text.edit.end", "Stop editing text", "{}", false, |s, _| {
|
||||||
textedit::end(s)?;
|
textedit::end(s)?;
|
||||||
ok()
|
ok()
|
||||||
}),
|
}),
|
||||||
spec!("text.fonts", "List fonts", "{family? (list its styles)}", false, |_, p| {
|
spec!(
|
||||||
Ok(match string(p, "family") {
|
"text.fonts",
|
||||||
Some(f) => json!(figcraft_text::family_styles(&f)),
|
"List fonts",
|
||||||
None => json!(figcraft_text::family_names()),
|
"{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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,18 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
|
||||||
Ok(())
|
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<()> {
|
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| {
|
edit(s, |t, te| {
|
||||||
let (a, b) = range(te);
|
let (a, b) = range(te);
|
||||||
t.replace_range(a, b, &text);
|
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)
|
figcraft_text::layout_node(node).map(|(l, _)| l)
|
||||||
};
|
};
|
||||||
let (a, b) = range(&te);
|
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 {
|
let next = match key {
|
||||||
"Left" if !shift && a != b => a,
|
"Left" if !shift && a != b && !word && !line => a,
|
||||||
"Right" if !shift && a != b => b,
|
"Right" if !shift && a != b && !word && !line => b,
|
||||||
"Left" if line => line_start(&chars, cur),
|
"Left" if line => line_start(&chars, cur),
|
||||||
"Right" if line => line_end(&chars, cur),
|
"Right" if line => line_end(&chars, cur),
|
||||||
"Left" if word => word_left(&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`.
|
/// Place the caret (and optionally extend the selection) at char `index`.
|
||||||
pub fn set_caret(s: &mut Session, index: usize, extend: bool) -> Result<()> {
|
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()?;
|
let ds = s.ds_mut()?;
|
||||||
if let Some(te) = ds.text_edit.as_mut() {
|
if let Some(te) = ds.text_edit.as_mut() {
|
||||||
te.caret = index;
|
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 Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
|
||||||
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
|
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
|
||||||
let mut a = index.min(chars.len());
|
let mut a = index.min(chars.len());
|
||||||
let mut b = a;
|
// Double-clicking just after a word's last char (or at the end) selects that word.
|
||||||
while a > 0 && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
|
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;
|
a -= 1;
|
||||||
}
|
}
|
||||||
while b < chars.len() && chars.get(b).is_some_and(|c| is_word(*c)) {
|
let mut b = a;
|
||||||
b += 1;
|
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()?;
|
let ds = s.ds_mut()?;
|
||||||
if let Some(te) = ds.text_edit.as_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(())
|
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.
|
/// The selected range while editing (for styling commands), or None.
|
||||||
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
|
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
|
||||||
let te = s.ds().ok()?.text_edit.clone()?;
|
let te = s.ds().ok()?.text_edit.clone()?;
|
||||||
let (a, b) = range(&te);
|
let (a, b) = range(&te);
|
||||||
Some((te.id, a, b))
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ pub struct Face {
|
||||||
pub underline_thickness: f64,
|
pub underline_thickness: f64,
|
||||||
pub strike_pos: f64,
|
pub strike_pos: f64,
|
||||||
pub shaper: harfrust::ShaperData,
|
pub shaper: harfrust::ShaperData,
|
||||||
|
/// OpenType GSUB feature tags the face provides (e.g. `smcp`).
|
||||||
|
gsub_features: Vec<[u8; 4]>,
|
||||||
charmap: HashMap<u32, u32>,
|
charmap: HashMap<u32, u32>,
|
||||||
outlines: Mutex<HashMap<u32, Arc<BezPath>>>,
|
outlines: Mutex<HashMap<u32, Arc<BezPath>>>,
|
||||||
advances: Mutex<HashMap<u32, f64>>,
|
advances: Mutex<HashMap<u32, f64>>,
|
||||||
|
|
@ -82,6 +84,14 @@ impl Face {
|
||||||
let upem = f64::from(m.units_per_em.max(1));
|
let upem = f64::from(m.units_per_em.max(1));
|
||||||
let ascent = f64::from(m.ascent);
|
let ascent = f64::from(m.ascent);
|
||||||
let descent = -f64::from(m.descent);
|
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 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 under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
|
||||||
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
|
let 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),
|
underline_thickness: under.1.max(upem * 0.02),
|
||||||
strike_pos: strike,
|
strike_pos: strike,
|
||||||
shaper,
|
shaper,
|
||||||
|
gsub_features,
|
||||||
charmap,
|
charmap,
|
||||||
bytes,
|
bytes,
|
||||||
outlines: Mutex::new(HashMap::new()),
|
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 {
|
pub fn has_char(&self, c: char) -> bool {
|
||||||
self.charmap.get(&(c as u32)).is_some_and(|g| *g != 0)
|
self.charmap.get(&(c as u32)).is_some_and(|g| *g != 0)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
use std::sync::Arc;
|
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 figcraft_geom::{Affine, BezPath, Rect};
|
||||||
use harfrust::{Direction, ShapeOptions, UnicodeBuffer};
|
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
|
||||||
|
|
||||||
use crate::fontdb::{Face, db};
|
use crate::fontdb::{Face, db};
|
||||||
|
|
||||||
|
|
@ -78,6 +78,8 @@ fn natural_ratio(face: &Face) -> f64 {
|
||||||
|
|
||||||
struct Item {
|
struct Item {
|
||||||
face: Arc<Face>,
|
face: Arc<Face>,
|
||||||
|
/// Glyph scale relative to the style's size (synthesised small caps).
|
||||||
|
scale: f64,
|
||||||
gid: u32,
|
gid: u32,
|
||||||
adv: f64,
|
adv: f64,
|
||||||
dx: f64,
|
dx: f64,
|
||||||
|
|
@ -126,6 +128,14 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||||
}
|
}
|
||||||
drop(dbw);
|
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.
|
// Shape each span, split by paragraph, falling back per char.
|
||||||
let mut items: Vec<Item> = Vec::with_capacity(chars.len());
|
let mut items: Vec<Item> = Vec::with_capacity(chars.len());
|
||||||
for (si, (start, end, _)) in spans.iter().enumerate() {
|
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' {
|
if first == '\n' {
|
||||||
items.push(Item {
|
items.push(Item {
|
||||||
face: face.clone(),
|
face: face.clone(),
|
||||||
|
scale: 1.0,
|
||||||
gid: 0,
|
gid: 0,
|
||||||
adv: 0.0,
|
adv: 0.0,
|
||||||
dx: 0.0,
|
dx: 0.0,
|
||||||
|
|
@ -162,18 +173,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||||
ch: '\n',
|
ch: '\n',
|
||||||
});
|
});
|
||||||
} else {
|
} 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;
|
seg_start = seg_end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List markers.
|
// List markers sit in a gutter and wrapped lines hang at the text start; paragraph indent
|
||||||
let marker_width = match text.list {
|
// applies to plain paragraphs only.
|
||||||
ListStyle::None => 0.0,
|
let marker_width = list_gutter(text.list, &chars, styles.first(), faces.first().cloned().flatten().as_ref());
|
||||||
_ => styles.first().map(|s| sanitize_size(s.font_size) * 1.5).unwrap_or(18.0),
|
let indent = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
|
||||||
};
|
|
||||||
let indent = text.paragraph_indent.max(0.0);
|
|
||||||
|
|
||||||
// Line breaking.
|
// Line breaking.
|
||||||
let wrap = match text.auto_resize {
|
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),
|
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent - width) / gaps as f64).max(0.0),
|
||||||
_ => 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(&[]) {
|
for it in items.get(*s..*e).unwrap_or(&[]) {
|
||||||
if it.ch == '\n' {
|
if it.ch == '\n' {
|
||||||
continue;
|
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() {
|
if it.gid != 0 || !it.is_space() {
|
||||||
out.glyphs.push(Glyph {
|
out.glyphs.push(Glyph {
|
||||||
face: it.face.clone(),
|
face: it.face.clone(),
|
||||||
|
|
@ -305,35 +347,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||||
pen += justify_extra;
|
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_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);
|
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 {
|
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;
|
let mut j = 0;
|
||||||
while j < gl.len() {
|
while j < gl.len() {
|
||||||
let Some(g0) = gl.get(j) else { break };
|
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 st = out.styles.get(g0.style).cloned().unwrap_or_default();
|
||||||
let mut k = j + 1;
|
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;
|
k += 1;
|
||||||
}
|
}
|
||||||
if st.decoration != TextDecoration::None
|
if st.decoration != TextDecoration::None
|
||||||
|
|
@ -429,6 +447,25 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||||
out
|
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 {
|
fn count_paragraphs_before(chars: &[char], pos: usize) -> usize {
|
||||||
chars.iter().take(pos).filter(|c| **c == '\n').count()
|
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 k = last.size / face.upem;
|
||||||
let adv = face.advance(gid) * k;
|
let adv = face.advance(gid) * k;
|
||||||
let limit = wrap.unwrap_or(f64::INFINITY);
|
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) {
|
while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.x + g.advance + adv > limit) {
|
||||||
out.glyphs.pop();
|
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);
|
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 {
|
out.glyphs.push(Glyph {
|
||||||
face,
|
face,
|
||||||
|
|
@ -468,48 +508,70 @@ fn sanitize_size(s: f64) -> f64 {
|
||||||
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
|
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
/// One run of chars shaped with a single face and style.
|
||||||
fn shape_segment(
|
struct Segment<'a> {
|
||||||
chars: &[char],
|
|
||||||
start: usize,
|
start: usize,
|
||||||
end: usize,
|
end: usize,
|
||||||
face: &Arc<Face>,
|
style: &'a TextStyle,
|
||||||
style: &TextStyle,
|
|
||||||
size: f64,
|
size: f64,
|
||||||
spacing: f64,
|
spacing: f64,
|
||||||
st_idx: usize,
|
st_idx: usize,
|
||||||
items: &mut Vec<Item>,
|
}
|
||||||
) {
|
|
||||||
let k = size / face.upem;
|
/// Synthesised small caps: lower-case letters drawn as capitals at this fraction of the size.
|
||||||
let seg: String = chars.get(start..end).unwrap_or(&[]).iter().collect();
|
const SMALL_CAP_SCALE: f64 = 0.72;
|
||||||
let cased = style.case.apply(&seg);
|
|
||||||
|
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();
|
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new();
|
||||||
if let Some(hb) = face.hb() {
|
if let Some(hb) = face.hb() {
|
||||||
let shaper = face.shaper.shaper(&hb).build();
|
let shaper = face.shaper.shaper(&hb).build();
|
||||||
let mut buf = UnicodeBuffer::new();
|
let mut buf = UnicodeBuffer::new();
|
||||||
if cased.chars().count() == seg.chars().count() {
|
for (u, src, _) in &mapped {
|
||||||
for (i, c) in cased.chars().enumerate() {
|
buf.add(*u, *src as u32);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
buf.set_direction(Direction::LeftToRight);
|
buf.set_direction(Direction::LeftToRight);
|
||||||
buf.guess_segment_properties();
|
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()) {
|
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));
|
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (i, c) in seg.chars().enumerate() {
|
for (u, src, _) in &mapped {
|
||||||
let gid = face.glyph_for(c);
|
let gid = face.glyph_for(*u);
|
||||||
raw.push((gid, (start + i) as u32, face.advance(gid) as i32, 0, 0));
|
raw.push((gid, *src as u32, face.advance(gid) as i32, 0, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let n = raw.len();
|
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 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 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 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 {
|
items.push(Item {
|
||||||
face: face.clone(),
|
face: face.clone(),
|
||||||
|
scale,
|
||||||
gid,
|
gid,
|
||||||
adv,
|
adv,
|
||||||
dx: f64::from(xo) * k,
|
dx: f64::from(xo) * k * scale,
|
||||||
dy: f64::from(yo) * k,
|
dy: f64::from(yo) * k * scale,
|
||||||
char_start: cl,
|
char_start: cl,
|
||||||
char_end: cend,
|
char_end: cend,
|
||||||
style: st_idx,
|
style: seg.st_idx,
|
||||||
ch,
|
ch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,158 @@ fn glyph_paths_have_area() {
|
||||||
let p = l.to_path();
|
let p = l.to_path();
|
||||||
assert!(p.area().abs() > 1.0);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
for ev in events {
|
||||||
match ev {
|
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) {
|
if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) {
|
||||||
app.toast(e.to_string());
|
app.toast(e.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Paste(t) => {
|
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 => {
|
Event::Copy | Event::Cut => {
|
||||||
if let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session)
|
if let Some(text) = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()) {
|
||||||
&& 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>())
|
|
||||||
{
|
|
||||||
app.services.clipboard_set_text(&text);
|
app.services.clipboard_set_text(&text);
|
||||||
if matches!(ev, Event::Cut) {
|
if matches!(ev, Event::Cut) {
|
||||||
let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false);
|
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, .. } => {
|
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 {
|
let name = match key {
|
||||||
Key::Backspace => "Backspace",
|
Key::Backspace => "Backspace",
|
||||||
Key::Delete => "Delete",
|
Key::Delete => "Delete",
|
||||||
|
|
@ -234,18 +239,6 @@ fn text_keys(app: &mut FigApp, _ctx: &Context, events: &[Event]) {
|
||||||
Key::Escape => "Escape",
|
Key::Escape => "Escape",
|
||||||
Key::Tab => "Tab",
|
Key::Tab => "Tab",
|
||||||
Key::A if m.command => "SelectAll",
|
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 => {
|
Key::Z if m.command => {
|
||||||
let _ = figcraft_engine::textedit::end(&mut app.session);
|
let _ = figcraft_engine::textedit::end(&mut app.session);
|
||||||
app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
|
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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,90 @@
|
||||||
//! Typography section of the Design panel.
|
//! Typography section of the Design panel: font family (searchable, previewed, recent first),
|
||||||
#![allow(unused_imports)]
|
//! 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 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 figcraft_doc::*;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::FigApp;
|
use crate::FigApp;
|
||||||
use crate::icons::{self, Icon};
|
use crate::icons::{self, Icon};
|
||||||
use crate::right::{common, menu_item, row};
|
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};
|
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]) {
|
pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||||
let t = Tokens::get(ui.ctx());
|
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();
|
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 range = figcraft_engine::textedit::selected_range(&app.session);
|
||||||
let style_of = |n: &Node| -> Option<TextStyle> {
|
let editing = range.is_some();
|
||||||
let td = n.kind.text()?;
|
let styles = target_styles(doc, &ids, range);
|
||||||
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 apply_style = |app: &mut FigApp, key: &str, v: Value| {
|
let apply_style = |app: &mut FigApp, key: &str, v: Value| {
|
||||||
if let Some((id, a, b)) = range
|
if let Some((id, a, b)) = range
|
||||||
&& a != b
|
&& 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 }));
|
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| {
|
row(ui, 32.0, |ui| {
|
||||||
let w = ui.available_width();
|
let w = ui.available_width();
|
||||||
let resp = widgets::dropdown(ui, &family, w, None);
|
let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(MIXED), w, None);
|
||||||
egui::Popup::menu(&resp).width(260.0).show(|ui| {
|
egui::Popup::menu(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||||
let q_id = Id::new("font_query");
|
if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) {
|
||||||
let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
|
remember_family(ui.ctx(), &f);
|
||||||
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(240.0));
|
apply_style(app, "fontFamily", json!(f));
|
||||||
ui.data_mut(|d| d.insert_temp(q_id, q.clone()));
|
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| {
|
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)
|
for v in SIZES {
|
||||||
{
|
let mark = if size == Some(v) { "✓ " } else { " " };
|
||||||
if menu_item(ui, &f, "") {
|
if menu_item(ui, &format!("{mark}{}", trim_num(v)), "") {
|
||||||
apply_style(app, "fontFamily", json!(f));
|
apply_style(app, "fontSize", json!(v));
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
|
||||||
|
// Line height + letter spacing.
|
||||||
row(ui, 32.0, |ui| {
|
row(ui, 32.0, |ui| {
|
||||||
let resp = widgets::dropdown(ui, &fstyle, half, None);
|
let lh = uniform(&styles, |s| s.line_height);
|
||||||
let fam = family.clone();
|
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| MIXED.into());
|
||||||
egui::Popup::menu(&resp).width(200.0).show(|ui| {
|
if let Some(v) = lead_field(ui, Id::new("ty_lh"), LeadGlyph::LineHeight, &lh_label, half, "Line height")
|
||||||
for s in figcraft_text::family_styles(&fam) {
|
&& let Some(v) = parse_line_height(&v, lh)
|
||||||
if menu_item(ui, &s, "") {
|
{
|
||||||
apply_style(app, "fontStyle", json!(s));
|
apply_style(app, "lineHeight", v);
|
||||||
ui.close();
|
}
|
||||||
}
|
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);
|
if let Some(i) = widgets::segmented(ui, &items, active, (half / 4.0).floor()) {
|
||||||
let e = widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half, 1.0);
|
let v = ["widthAndHeight", "height", "none", "truncate"].get(i).copied().unwrap_or("none");
|
||||||
match e {
|
app.set_props(&ids, json!({"textAutoResize": v}));
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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| {
|
row(ui, 32.0, |ui| {
|
||||||
let items = [
|
let items = [
|
||||||
(Icon::TextAlignLeft, "Align left"),
|
(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::TextAlignRight, "Align right"),
|
||||||
(Icon::TextAlignJustify, "Justify"),
|
(Icon::TextAlignJustify, "Justify"),
|
||||||
];
|
];
|
||||||
let active = td.map(|(h, _, _)| match h {
|
let active = align.map(|(h, _)| match h {
|
||||||
TextAlignH::Left => 0,
|
TextAlignH::Left => 0,
|
||||||
TextAlignH::Center => 1,
|
TextAlignH::Center => 1,
|
||||||
TextAlignH::Right => 2,
|
TextAlignH::Right => 2,
|
||||||
TextAlignH::Justified => 3,
|
TextAlignH::Justified => 3,
|
||||||
});
|
});
|
||||||
if let Some(i) = widgets::segmented(ui, &items, active, 26.0) {
|
if let Some(i) = widgets::segmented(ui, &items, active, 24.0) {
|
||||||
app.set_props(&ids, json!({"textAlign": (["left", "center", "right", "justified"][i])}));
|
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 items = [(Icon::TextTop, "Align top"), (Icon::TextMiddle, "Align middle"), (Icon::TextBottom, "Align bottom")];
|
||||||
let active = td.map(|(_, v, _)| match v {
|
let active = align.map(|(_, v)| match v {
|
||||||
TextAlignV::Top => 0,
|
TextAlignV::Top => 0,
|
||||||
TextAlignV::Middle => 1,
|
TextAlignV::Middle => 1,
|
||||||
TextAlignV::Bottom => 2,
|
TextAlignV::Bottom => 2,
|
||||||
});
|
});
|
||||||
if let Some(i) = widgets::segmented(ui, &items, active, 26.0) {
|
if let Some(i) = widgets::segmented(ui, &items, active, 24.0) {
|
||||||
app.set_props(&ids, json!({"textAlignVertical": (["top", "middle", "bottom"][i])}));
|
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 {
|
/// The Type settings popover: Basics (decoration, case, list, indent, truncation) and Details
|
||||||
TextAutoResize::WidthAndHeight => 0,
|
/// (OpenType features).
|
||||||
TextAutoResize::Height => 1,
|
fn type_settings(
|
||||||
TextAutoResize::None => 2,
|
app: &mut FigApp,
|
||||||
TextAutoResize::Truncate => 3,
|
ui: &mut Ui,
|
||||||
});
|
doc: &Document,
|
||||||
if let Some(i) = widgets::segmented(ui, &items, active, 26.0) {
|
ids: &[NodeId],
|
||||||
app.set_props(&ids, json!({"textAutoResize": (["widthAndHeight", "height", "none", "truncate"][i])}));
|
styles: &[TextStyle],
|
||||||
}
|
apply_style: &dyn Fn(&mut FigApp, &str, Value),
|
||||||
let deco = st.as_ref().map(|s| s.decoration);
|
) {
|
||||||
let more = icons::button(ui, Icon::Dots, 24.0, false, "Type details");
|
let t = Tokens::get(ui.ctx());
|
||||||
egui::Popup::menu(&more).width(220.0).show(|ui| {
|
let tab_id = Id::new("ty_settings_tab");
|
||||||
ui.label(egui::RichText::new("Decoration").font(semibold(11.0)));
|
let mut tab: usize = ui.data(|d| d.get_temp(tab_id)).unwrap_or(0);
|
||||||
for (l, v) in [("None", "none"), ("Underline", "underline"), ("Strikethrough", "strikethrough")] {
|
ui.horizontal(|ui| {
|
||||||
if menu_item(ui, &format!("{}{l}", if format!("{deco:?}").eq_ignore_ascii_case(&format!("Some({l})")) { "✓ " } else { "" }), "") {
|
for (i, l) in ["Basics", "Details"].iter().enumerate() {
|
||||||
apply_style(app, "textDecoration", json!(v));
|
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() {
|
||||||
ui.separator();
|
tab = i;
|
||||||
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.data_mut(|d| d.insert_temp(tab_id, tab));
|
||||||
}
|
ui.add_space(4.0);
|
||||||
}
|
let w = ui.available_width();
|
||||||
ui.separator();
|
if tab == 0 {
|
||||||
ui.label(egui::RichText::new("List").font(semibold(11.0)));
|
let deco = uniform(styles, |s| s.decoration);
|
||||||
for (l, v) in [("None", "none"), ("Bulleted", "bulleted"), ("Numbered", "numbered")] {
|
caption(ui, "Decoration");
|
||||||
if menu_item(ui, l, "") {
|
let active = deco.map(|d| match d {
|
||||||
app.set_props(&ids, json!({"listStyle": v}));
|
TextDecoration::None => 0,
|
||||||
}
|
TextDecoration::Underline => 1,
|
||||||
}
|
TextDecoration::Strikethrough => 2,
|
||||||
});
|
});
|
||||||
});
|
if let Some(i) = text_seg(ui, "deco", &[("—", "None"), ("U", "Underline"), ("S", "Strikethrough")], active, w) {
|
||||||
let _ = t;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue