mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 21:36:46 -06:00
- Inline IME composition: egui Preedit events become a composition held in the text edit
session (`TextEdit.preedit`), shown inline at the caret with an underline and the IME cursor,
reflowing the text; the document only gets the committed text (`text.edit.insert`). Composing
over a selection replaces it; moving the caret or ending the edit drops an uncommitted
composition. The IME candidate window follows the cursor inside the composition.
New command `text.edit.preedit {text, cursor?}`; `text.edit.selection` reports it.
- Variable fonts: faces read their fvar axes (tag, name, range, default, hidden) and named
instances. Named instances are offered as styles and resolve in `find`; a style name with no
instance implies weight/italic/slant on the axes and optical size follows the font size.
Per-run custom axis values (`TextStyle.variations`, serde default, omitted when empty) are applied
in HarfRust shaping (ShaperInstance) and skrifa outlines/advances (cached per coordinates).
PDF export outlines non-default instances instead of embedding the default one.
- Commands/props: `fontVariations` / `variationSettings` props (whole layer or start..end),
`text.setVariations {variations, replace?}` (merges; edited range while editing),
`text.fonts {family, style?, axes: true}`; `node.get` and copy/paste properties carry the axes.
Picking a named style (Bold/Italic toggles, weight steps, fontStyle/fontWeight) drops custom
wght/ital/slnt values and keeps the others.
- Type settings ▸ Variable tab for variable fonts: a slider (default tick, double-click to reset)
and a field per visible axis, "Mixed" across differing styles, live drag as one undo step,
"Reset to style". Long style/family names stay on one line with an ellipsis and a tooltip.
- Tests: variable font shaping/outlines/clamping/instances (skipped without a variable font),
serde backward compatibility, preedit lifecycle and undo, variation commands/props/undo/junk;
junk sweeps extended with variation and preedit params.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
330 lines
12 KiB
Rust
330 lines
12 KiB
Rust
use super::*;
|
|
use figcraft_doc::{TextAlignH, TextData, TextDecoration};
|
|
|
|
fn have_fonts() -> bool {
|
|
db().write().unwrap().default_face().is_some() || {
|
|
scan_system_fonts();
|
|
db().write().unwrap().default_face().is_some()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lays_out_single_line() {
|
|
if !have_fonts() {
|
|
eprintln!("no fonts; skipping");
|
|
return;
|
|
}
|
|
let mut t = TextData::new("Hello");
|
|
t.style.font_size = 20.0;
|
|
let l = layout(&t, None, None);
|
|
assert_eq!(l.lines.len(), 1);
|
|
assert!(l.glyphs.len() >= 5);
|
|
assert!(l.width > 30.0 && l.width < 100.0, "{}", l.width);
|
|
assert!(l.height > 20.0 && l.height < 30.0, "{}", l.height);
|
|
// Caret moves right.
|
|
assert!(l.caret(3).0 > l.caret(1).0);
|
|
assert_eq!(l.hit(-5.0, 5.0), 0);
|
|
assert_eq!(l.hit(1000.0, 5.0), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn newline_and_wrap() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("one two three four five six\nseven");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::Height;
|
|
let l = layout(&t, Some(60.0), None);
|
|
assert!(l.lines.len() >= 3, "{}", l.lines.len());
|
|
assert!(l.lines.iter().all(|ln| ln.width <= 60.0 + 1e-6 || ln.glyph_end - ln.glyph_start <= 1));
|
|
// Auto width ignores the box.
|
|
t.auto_resize = figcraft_doc::TextAutoResize::WidthAndHeight;
|
|
assert_eq!(layout(&t, Some(60.0), None).lines.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn alignment_and_decoration() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("Hi");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::None;
|
|
t.align_h = TextAlignH::Right;
|
|
t.style.decoration = TextDecoration::Underline;
|
|
let l = layout(&t, Some(200.0), None);
|
|
let last = l.glyphs.last().unwrap();
|
|
assert!((last.x + last.advance - 200.0).abs() < 0.5);
|
|
assert_eq!(l.decorations.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_hostile_inputs() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("");
|
|
let l = layout(&t, None, None);
|
|
assert_eq!(l.lines.len(), 1);
|
|
assert!(l.height > 0.0);
|
|
t.characters = "\n\n".into();
|
|
assert_eq!(layout(&t, None, None).lines.len(), 3);
|
|
t.style.font_size = f64::NAN;
|
|
t.characters = "x".into();
|
|
let _ = layout(&t, Some(f64::NAN), Some(-1.0));
|
|
t.runs.push(figcraft_doc::StyleRun { start: 5, end: 2, style: Default::default() });
|
|
let _ = layout(&t, None, None);
|
|
let mut emoji = TextData::new("a😀b 漢字 ß");
|
|
emoji.style.case = figcraft_doc::TextCase::Upper;
|
|
let l = layout(&emoji, None, None);
|
|
assert!(!l.glyphs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn truncate() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("a very long line of text that will not fit at all");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::Truncate;
|
|
let l = layout(&t, Some(80.0), Some(16.0));
|
|
assert!(l.truncated);
|
|
assert_eq!(l.lines.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn glyph_paths_have_area() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
use figcraft_geom::Shape;
|
|
let t = TextData::new("O");
|
|
let l = layout(&t, None, None);
|
|
let p = l.to_path();
|
|
assert!(p.area().abs() > 1.0);
|
|
}
|
|
|
|
fn inter() -> Option<std::sync::Arc<Face>> {
|
|
if !have_fonts() {
|
|
return None;
|
|
}
|
|
db().write().unwrap().find("Inter", "Regular").filter(|f| f.family == "Inter")
|
|
}
|
|
|
|
#[test]
|
|
fn opentype_features_change_shaping() {
|
|
let Some(face) = inter() else {
|
|
eprintln!("Inter not embedded; skipping");
|
|
return;
|
|
};
|
|
let mut t = TextData::new("1111 0");
|
|
t.style.font_size = 20.0;
|
|
let base = layout(&t, None, None);
|
|
if face.has_feature(b"tnum") {
|
|
t.features.insert("tnum".into(), true);
|
|
let tab = layout(&t, None, None);
|
|
assert!((tab.width - base.width).abs() > 0.01, "tnum should change digit widths");
|
|
t.features.clear();
|
|
}
|
|
if face.has_feature(b"zero") {
|
|
t.features.insert("zero".into(), true);
|
|
let z = layout(&t, None, None);
|
|
let zero_gid = |l: &TextLayout| l.glyphs.iter().find(|g| g.char_start == 5).map(|g| g.gid);
|
|
assert_ne!(zero_gid(&z), zero_gid(&base), "zero should pick the slashed zero");
|
|
}
|
|
// Invalid tags are ignored, never a crash.
|
|
t.features.insert("bogus-tag".into(), true);
|
|
t.features.insert("".into(), false);
|
|
let _ = layout(&t, None, None);
|
|
}
|
|
|
|
#[test]
|
|
fn small_caps_native_or_synthesised() {
|
|
let Some(face) = inter() else { return };
|
|
let mut t = TextData::new("Ab");
|
|
t.style.font_size = 20.0;
|
|
let orig = layout(&t, None, None);
|
|
t.style.case = figcraft_doc::TextCase::SmallCaps;
|
|
let sc = layout(&t, None, None);
|
|
let b = |l: &TextLayout| l.glyphs.iter().find(|g| g.char_start == 1).cloned().unwrap();
|
|
let (ob, sb) = (b(&orig), b(&sc));
|
|
assert_ne!(ob.gid, sb.gid, "the lower-case b becomes a small capital");
|
|
if !face.has_feature(b"smcp") {
|
|
// Synthesised: a capital B drawn smaller than the A.
|
|
assert_eq!(sb.gid, face.glyph_for('B'));
|
|
assert!(sb.size < 20.0);
|
|
}
|
|
// Upper-case letters are untouched.
|
|
assert_eq!(sc.glyphs.iter().find(|g| g.char_start == 0).unwrap().gid, face.glyph_for('A'));
|
|
}
|
|
|
|
#[test]
|
|
fn title_case_across_runs() {
|
|
let Some(face) = inter() else { return };
|
|
let mut t = TextData::new("hello world");
|
|
t.style.case = figcraft_doc::TextCase::Title;
|
|
// "hel" bold, "lo world" regular: the word continues across the run boundary.
|
|
t.restyle(0, 3, |s| s.font_style = "Bold".into());
|
|
let l = layout(&t, None, None);
|
|
let gid_at = |i: usize| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid);
|
|
assert_eq!(gid_at(3), Some(face.glyph_for('l')), "mid-word char after a run boundary stays lower-case");
|
|
assert_eq!(gid_at(6), Some(face.glyph_for('W')));
|
|
}
|
|
|
|
#[test]
|
|
fn lists_hang_and_number() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("one two three four five six seven\nb");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::Height;
|
|
t.list = figcraft_doc::ListStyle::Bulleted;
|
|
t.paragraph_indent = 30.0;
|
|
let l = layout(&t, Some(90.0), None);
|
|
assert!(l.lines.len() >= 3);
|
|
let first_text = |li: usize| {
|
|
let ln = &l.lines[li];
|
|
l.glyphs[ln.glyph_start..ln.glyph_end].iter().find(|g| g.char_end > g.char_start).map(|g| g.x).unwrap()
|
|
};
|
|
// Wrapped lines hang at the same x as the first line's text (paragraph indent is ignored in lists).
|
|
assert!((first_text(0) - first_text(1)).abs() < 0.01);
|
|
assert!(first_text(0) > 0.0);
|
|
// The bullet sits left of the text, and carets never land on it.
|
|
let ln = &l.lines[0];
|
|
let marker = l.glyphs[ln.glyph_start..ln.glyph_end].iter().find(|g| g.char_end == g.char_start).unwrap();
|
|
assert!(marker.x < first_text(0));
|
|
assert!(l.caret(0).0 >= first_text(0) - 0.01);
|
|
|
|
// Numbered: ten paragraphs, the text column clears "10.".
|
|
let mut n = TextData::new((1..=10).map(|i| format!("p{i}")).collect::<Vec<_>>().join("\n"));
|
|
n.list = figcraft_doc::ListStyle::Numbered;
|
|
let l = layout(&n, None, None);
|
|
assert_eq!(l.lines.len(), 10);
|
|
let last = &l.lines[9];
|
|
let gl = &l.glyphs[last.glyph_start..last.glyph_end];
|
|
let marker_end = gl.iter().filter(|g| g.char_end == g.char_start).map(|g| g.x + g.advance).fold(0.0, f64::max);
|
|
let text_x = gl.iter().find(|g| g.char_end > g.char_start).unwrap().x;
|
|
assert!(marker_end < text_x, "{marker_end} < {text_x}");
|
|
}
|
|
|
|
#[test]
|
|
fn paragraph_indent_first_line_only() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("aaa bbb ccc ddd eee fff ggg");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::Height;
|
|
t.paragraph_indent = 20.0;
|
|
let l = layout(&t, Some(70.0), None);
|
|
assert!(l.lines.len() >= 2);
|
|
let x0 = |li: usize| l.glyphs[l.lines[li].glyph_start].x;
|
|
assert!((x0(0) - 20.0).abs() < 0.5, "{}", x0(0));
|
|
assert!(x0(1) < 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_max_lines_with_ellipsis() {
|
|
let Some(face) = inter() else { return };
|
|
let mut t = TextData::new("first line\nsecond line\nthird line");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::Truncate;
|
|
t.max_lines = 2;
|
|
let l = layout(&t, Some(300.0), Some(1000.0));
|
|
assert!(l.truncated);
|
|
assert_eq!(l.lines.len(), 2);
|
|
let last = l.glyphs.last().unwrap();
|
|
assert_eq!(last.gid, face.glyph_for('…'));
|
|
// max_lines is ignored unless truncating.
|
|
t.auto_resize = figcraft_doc::TextAutoResize::None;
|
|
assert_eq!(layout(&t, Some(300.0), Some(1000.0)).lines.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn vertical_alignment_in_fixed_box() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let mut t = TextData::new("Hi");
|
|
t.auto_resize = figcraft_doc::TextAutoResize::None;
|
|
t.align_v = figcraft_doc::TextAlignV::Bottom;
|
|
let mut n = figcraft_doc::Node::new(figcraft_doc::NodeId(1), figcraft_doc::NodeKind::Text(t.clone()), "t", 100.0, 100.0);
|
|
let (l, dy) = layout_node(&n).unwrap();
|
|
assert!((dy + l.height - 100.0).abs() < 1e-6);
|
|
t.align_v = figcraft_doc::TextAlignV::Middle;
|
|
n.kind = figcraft_doc::NodeKind::Text(t.clone());
|
|
let (l, dy) = layout_node(&n).unwrap();
|
|
assert!((dy - (100.0 - l.height) / 2.0).abs() < 1e-6);
|
|
// Auto height ignores vertical alignment.
|
|
t.auto_resize = figcraft_doc::TextAutoResize::Height;
|
|
n.kind = figcraft_doc::NodeKind::Text(t);
|
|
assert_eq!(layout_node(&n).unwrap().1, 0.0);
|
|
}
|
|
|
|
/// A variable font with a usable weight axis, if one is installed (craft-fonts or the system).
|
|
fn variable_face() -> Option<std::sync::Arc<Face>> {
|
|
if !have_fonts() {
|
|
return None;
|
|
}
|
|
scan_system_fonts();
|
|
["Inter", "Bahnschrift", "Segoe UI Variable", "Roboto Flex", "Noto Sans", "Cantarell", "Source Sans 3", "Open Sans", "Roboto"]
|
|
.into_iter()
|
|
.filter_map(|fam| find_face(fam, "Regular").filter(|f| f.family.eq_ignore_ascii_case(fam)))
|
|
.find(|f| f.axis("wght").is_some_and(|a| a.max - a.min >= 200.0))
|
|
}
|
|
|
|
#[test]
|
|
fn variable_font_axes_change_shaping_and_outlines() {
|
|
let Some(face) = variable_face() else {
|
|
eprintln!("no variable font installed; skipping");
|
|
return;
|
|
};
|
|
let wght = face.axis("wght").cloned().unwrap();
|
|
let wi = face.axes.iter().position(|a| a.tag == "wght").unwrap();
|
|
let mut t = TextData::new("Hamburgefonts");
|
|
t.style.font_family = face.family.clone();
|
|
t.style.font_style = face.style.clone();
|
|
t.style.font_size = 40.0;
|
|
t.style.variations.insert("wght".into(), wght.min);
|
|
let light = layout(&t, None, None);
|
|
t.style.variations.insert("wght".into(), wght.max);
|
|
let heavy = layout(&t, None, None);
|
|
assert!(heavy.glyphs.iter().filter(|g| g.face.id == face.id).all(|g| !g.coords.is_empty()), "glyphs carry the instance");
|
|
let first = |l: &TextLayout| l.glyphs.first().map(|g| g.path().elements().to_vec());
|
|
assert_ne!(first(&light), first(&heavy), "the weight axis changes the outlines");
|
|
assert!(heavy.width >= light.width, "heavier is not narrower: {} < {}", heavy.width, light.width);
|
|
// Values clamp to the axis; unknown axes and junk are ignored.
|
|
t.style.variations.insert("wght".into(), 1e9);
|
|
t.style.variations.insert("ZZZZ".into(), 5.0);
|
|
t.style.variations.insert("wdth".into(), f64::NAN);
|
|
let v = face.axis_values(&t.style);
|
|
assert_eq!(v.len(), face.axes.len());
|
|
assert_eq!(v.get(wi).copied(), Some(wght.max));
|
|
assert!(v.iter().all(|x| x.is_finite()));
|
|
let _ = layout(&t, None, None);
|
|
// Without custom values, the style name implies the weight (or picks the named instance).
|
|
t.style.variations.clear();
|
|
t.style.font_style = "Bold".into();
|
|
let v = face.axis_values(&t.style);
|
|
let want = face.instance("Bold").and_then(|i| i.coords.get(wi).copied()).unwrap_or_else(|| wght.clamp(700.0));
|
|
assert_eq!(v.get(wi).copied(), Some(want));
|
|
// Named instances are offered as styles.
|
|
if face.instances.len() > 1 {
|
|
assert!(family_styles(&face.family).len() >= 2);
|
|
}
|
|
// The default instance needs no coordinates.
|
|
assert!(face.coords(&face.axes.iter().map(|a| a.default).collect::<Vec<_>>()).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn static_fonts_have_no_axes() {
|
|
if !have_fonts() {
|
|
return;
|
|
}
|
|
let Some(face) = db().write().unwrap().default_face() else { return };
|
|
if face.is_variable() {
|
|
return;
|
|
}
|
|
let mut st = figcraft_doc::TextStyle::default();
|
|
st.variations.insert("wght".into(), 900.0);
|
|
assert!(face.axis_values(&st).is_empty());
|
|
assert!(face.coords(&[700.0]).is_empty());
|
|
}
|