mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
Keep the right edge of right-aligned auto-width text as it grows
Fixes #1477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2af5e232d5
commit
71571cccad
@@ -54,7 +54,12 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
if let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
{
|
||||
let dw = w - n.width;
|
||||
let anchor_right = keeps_right_edge(doc, before, n);
|
||||
figcraft_layout::set_size(doc, id, w, h);
|
||||
if anchor_right && let Some(n) = doc.node_mut(id) {
|
||||
n.transform *= figcraft_geom::kurbo::Affine::translate((-dw, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
@@ -139,6 +144,15 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
cmd::comment::follow_anchors(doc, before);
|
||||
}
|
||||
|
||||
/// Whether auto-width text `n` keeps its right edge when its width changes: it was already
|
||||
/// right-aligned auto-width text before the edit and no auto layout flow places it.
|
||||
fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool {
|
||||
use figcraft_doc::{TextAlignH, TextAutoResize};
|
||||
let right_auto = |n: &Node| n.kind.text().is_some_and(|t| t.align_h == TextAlignH::Right && t.auto_resize == TextAutoResize::WidthAndHeight);
|
||||
let in_flow = !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && p.kind.is_frame_like());
|
||||
right_auto(n) && before.node(n.id).is_some_and(right_auto) && !in_flow
|
||||
}
|
||||
|
||||
/// Whether layer `id` changed (since `before`) in a way that can move or resize layers in an auto
|
||||
/// layout frame around it: new, resized, moved, shown or hidden, restructured, other text or
|
||||
/// layout settings.
|
||||
|
||||
@@ -370,3 +370,168 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of
|
||||
/// 1000 units wide), loaded into the font database so text measures the same on every machine.
|
||||
fn test_mono_font() -> &'static str {
|
||||
const FAMILY: &str = "FigCraft Test Mono";
|
||||
static LOADED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
LOADED.get_or_init(|| {
|
||||
let families = figcraft_text::load_font_bytes(synthetic_mono_font(FAMILY));
|
||||
assert_eq!(families, [FAMILY], "the synthetic font loads");
|
||||
});
|
||||
FAMILY
|
||||
}
|
||||
|
||||
fn synthetic_mono_font(family: &str) -> Vec<u8> {
|
||||
let be16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_be_bytes());
|
||||
let glyphs: u16 = 1 + (0x7E - 0x20 + 1);
|
||||
let mut head = Vec::new();
|
||||
for x in [0x0001_0000u32, 0x0001_0000, 0, 0x5F0F_3CF5] {
|
||||
head.extend_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
be16(&mut head, 0); // flags
|
||||
be16(&mut head, 1000); // unitsPerEm
|
||||
head.extend_from_slice(&[0; 16]); // created, modified
|
||||
for x in [0u16, (-200i16) as u16, 500, 800, 0, 8, 2, 0, 0] {
|
||||
be16(&mut head, x); // bbox, macStyle, lowestRecPPEM, direction hint, short loca, glyph format
|
||||
}
|
||||
let mut hhea = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
for x in [800u16, (-200i16) as u16, 0, 500, 0, 0, 500, 1, 0, 0, 0, 0, 0, 0, 0, glyphs] {
|
||||
be16(&mut hhea, x);
|
||||
}
|
||||
let mut maxp = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
be16(&mut maxp, glyphs);
|
||||
for x in [0u16, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] {
|
||||
be16(&mut maxp, x);
|
||||
}
|
||||
let mut hmtx = Vec::new();
|
||||
for _ in 0..glyphs {
|
||||
be16(&mut hmtx, 500);
|
||||
be16(&mut hmtx, 0);
|
||||
}
|
||||
let loca = vec![0; 2 * (usize::from(glyphs) + 1)];
|
||||
let glyf = vec![0; 4];
|
||||
// cmap: one Windows Unicode BMP subtable, format 4, 0x20..=0x7E → glyphs 1.. plus the end segment.
|
||||
let mut cmap = Vec::new();
|
||||
for x in [0u16, 1, 3, 1, 0, 12] {
|
||||
be16(&mut cmap, x);
|
||||
}
|
||||
for x in [4u16, 32, 0, 4, 4, 1, 0, 0x7E, 0xFFFF, 0, 0x20, 0xFFFF, (1i16 - 0x20) as u16, 1, 0, 0] {
|
||||
be16(&mut cmap, x);
|
||||
}
|
||||
let strings = [(1u16, family.to_string()), (2, "Regular".into()), (6, family.replace(' ', "") + "-Regular")];
|
||||
let mut name = Vec::new();
|
||||
be16(&mut name, 0);
|
||||
be16(&mut name, strings.len() as u16);
|
||||
be16(&mut name, 6 + 12 * strings.len() as u16);
|
||||
let mut storage = Vec::new();
|
||||
for (id, s) in &strings {
|
||||
let utf16: Vec<u8> = s.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for x in [3u16, 1, 0x409, *id, utf16.len() as u16, storage.len() as u16] {
|
||||
be16(&mut name, x);
|
||||
}
|
||||
storage.extend(utf16);
|
||||
}
|
||||
name.extend(storage);
|
||||
let mut post = 0x0003_0000u32.to_be_bytes().to_vec();
|
||||
post.extend_from_slice(&[0; 28]);
|
||||
let tables: [(&[u8; 4], Vec<u8>); 9] = [
|
||||
(b"cmap", cmap),
|
||||
(b"glyf", glyf),
|
||||
(b"head", head),
|
||||
(b"hhea", hhea),
|
||||
(b"hmtx", hmtx),
|
||||
(b"loca", loca),
|
||||
(b"maxp", maxp),
|
||||
(b"name", name),
|
||||
(b"post", post),
|
||||
];
|
||||
let mut font = 0x0001_0000u32.to_be_bytes().to_vec();
|
||||
for x in [tables.len() as u16, 128, 3, 16] {
|
||||
be16(&mut font, x);
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
font.extend_from_slice(*tag);
|
||||
font.extend_from_slice(&0u32.to_be_bytes());
|
||||
font.extend_from_slice(&(offset as u32).to_be_bytes());
|
||||
font.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
font.extend_from_slice(data);
|
||||
font.resize(font.len().next_multiple_of(4), 0);
|
||||
}
|
||||
font
|
||||
}
|
||||
|
||||
/// The world position of a text's top-right corner (where right-aligned text is anchored).
|
||||
fn top_right(s: &Session, t: NodeId) -> (f64, f64) {
|
||||
let doc = s.doc().unwrap();
|
||||
let p = doc.world_transform(t) * figcraft_geom::kurbo::Point::new(doc.node(t).unwrap().width, 0.0);
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn assert_near(a: (f64, f64), b: (f64, f64), why: &str) {
|
||||
assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{why}: {a:?} != {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_auto_width_text_grows_and_shrinks_to_the_left() {
|
||||
let font = test_mono_font();
|
||||
for (src, rotation) in [("ANCHOR TEST\nabc", 0.0), ("abc", 0.0), ("ANCHOR TEST\nabc", 90.0)] {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, src, json!({"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"x": 200, "y": 50, "rotation": rotation}})).unwrap();
|
||||
let anchor = top_right(&s, t);
|
||||
let w0 = size(&s, t).0;
|
||||
let widest = src.lines().map(|l| l.chars().count()).max().unwrap_or(0);
|
||||
assert!((w0 - 20.0 * widest as f64).abs() < 1e-6, "{src:?}: the test font measures 20 px a character, not {w0}");
|
||||
begin(&mut s, t, Some(src.chars().count()), false).unwrap();
|
||||
for c in "defghijklmnopqrstuvwxyz".chars() {
|
||||
crate::textedit::insert(&mut s, &c.to_string()).unwrap();
|
||||
assert_near(top_right(&s, t), anchor, &format!("{src:?} at {rotation}°: typing {c}"));
|
||||
}
|
||||
assert!(size(&s, t).0 > w0 + 1e-6, "{src:?} at {rotation}°: typing grows the text");
|
||||
for _ in 0..23 {
|
||||
key(&mut s, "Backspace", false, false, false).unwrap();
|
||||
assert_near(top_right(&s, t), anchor, &format!("{src:?} at {rotation}°: deleting"));
|
||||
}
|
||||
assert!((size(&s, t).0 - w0).abs() < 1e-6, "back to the starting width");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_auto_width_growth_undoes_to_its_old_place() {
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "abc", json!({"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}));
|
||||
let before = (s.doc().unwrap().node(t).unwrap().transform, size(&s, t));
|
||||
let anchor = top_right(&s, t);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghijklmnop"}})).unwrap();
|
||||
assert_near(top_right(&s, t), anchor, "set characters");
|
||||
let after = (s.doc().unwrap().node(t).unwrap().transform, size(&s, t));
|
||||
assert!(after.1.0 > before.1.0 + 1e-6, "the text grew");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().node(t).unwrap().transform, size(&s, t)), before, "undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().node(t).unwrap().transform, size(&s, t)), after, "redo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_places_right_aligned_auto_width_text() {
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
||||
s.execute("node.set", &json!({"id": f, "props": {"layout": "horizontal", "padding": 10, "gap": 0}})).unwrap();
|
||||
let t = id(s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"text": "abc", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}}),
|
||||
)
|
||||
.unwrap());
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghijklmnop"}})).unwrap();
|
||||
let c = s.doc().unwrap().node(t).unwrap().transform.as_coeffs();
|
||||
assert_eq!((c[4], c[5]), (10.0, 10.0), "the auto layout keeps it at its padding");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user