mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
text.create, text.createInPath, text.setText and text.setStyle answer with overflow: [id…] for the area type and type on a path whose text doesn't fit (left out when all fits), and MCP add_text with overflow: true. add_text takes area type's fit and fitMinPercent.
271 lines
14 KiB
Rust
271 lines
14 KiB
Rust
//! Area type fitting: Auto Size (the frame's height follows the text), Shrink Text to Fit, and
|
|
//! overflow reporting.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{AreaFit, NodeKind, TextKind, TextObject};
|
|
use vectorcraft_geom::Rect;
|
|
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
|
|
|
use super::*;
|
|
|
|
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId {
|
|
let mut a = json!({"width": w, "height": h});
|
|
if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) {
|
|
a.extend(e.clone());
|
|
}
|
|
let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap();
|
|
NodeId(id)
|
|
}
|
|
|
|
fn text(s: &Session, id: NodeId) -> TextObject {
|
|
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
|
NodeKind::Text(t) => (**t).clone(),
|
|
k => panic!("not text: {k:?}"),
|
|
}
|
|
}
|
|
|
|
fn frame(s: &Session, id: NodeId) -> Rect {
|
|
match text(s, id).kind {
|
|
TextKind::Area { frame } => frame.bounds().unwrap(),
|
|
k => panic!("not area type: {k:?}"),
|
|
}
|
|
}
|
|
|
|
fn overflows(s: &Session, id: NodeId) -> bool {
|
|
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).overflow
|
|
}
|
|
|
|
fn undo_len(s: &Session) -> usize {
|
|
s.doc().unwrap().history.undo.len()
|
|
}
|
|
|
|
#[test]
|
|
fn area_options_set_and_report_the_fit() {
|
|
let mut s = session();
|
|
let id = area(&mut s, 120.0, 30.0, STORY, json!({}));
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
let q = s.execute("text.areaOptions", &json!({})).unwrap();
|
|
assert_eq!((q["fit"].as_str(), q["fitMinPercent"].as_f64()), (Some("none"), Some(50.0)));
|
|
assert_eq!((q["overflow"].as_bool(), q["fitScale"].as_f64()), (Some(true), Some(1.0)));
|
|
let n = undo_len(&s);
|
|
let r = s.execute("text.areaOptions", &json!({"fit": "shrinkText", "fitMinPercent": 20})).unwrap();
|
|
assert_eq!(undo_len(&s), n + 1);
|
|
assert_eq!((r["fit"].as_str(), r["fitMinPercent"].as_f64()), (Some("shrinkText"), Some(20.0)));
|
|
assert_eq!(r["overflow"], false, "{r}");
|
|
let f = r["fitScale"].as_f64().unwrap();
|
|
assert!((0.2..1.0).contains(&f), "{f}");
|
|
assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 20.0 });
|
|
assert_eq!(text(&s, id).runs[0].style.size, 12.0, "the stored size stays");
|
|
// The minimum alone, clamped; the object form; and bad values.
|
|
s.execute("text.areaOptions", &json!({"fitMinPercent": 3})).unwrap();
|
|
assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 10.0 });
|
|
s.execute("text.areaOptions", &json!({"fit": {"shrinkText": {"minPercent": 95}}})).unwrap();
|
|
assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 95.0 });
|
|
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["overflow"], true, "95 % is not enough");
|
|
assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).is_err());
|
|
assert!(s.execute("text.areaOptions", &json!({"fit": 7})).is_err());
|
|
// The reply's own keys go back in unchanged (the dialog sends everything).
|
|
let q = s.execute("text.areaOptions", &json!({})).unwrap();
|
|
let n = undo_len(&s);
|
|
let again = s.execute("text.areaOptions", &q).unwrap();
|
|
assert_eq!(again, q);
|
|
assert_eq!(undo_len(&s), n + 1);
|
|
// Undo back to no fit.
|
|
while text(&s, id).area.fit != AreaFit::None {
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
}
|
|
assert!(overflows(&s, id));
|
|
}
|
|
|
|
#[test]
|
|
fn inspect_reports_overflow_and_fit_scale() {
|
|
let mut s = session();
|
|
let id = area(&mut s, 120.0, 30.0, STORY, json!({"fit": "shrinkText", "fitMinPercent": 10}));
|
|
s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap();
|
|
let doc = s.execute("document.inspect", &json!({})).unwrap();
|
|
let kids = doc["layers"][0]["children"].as_array().unwrap();
|
|
let node = kids.iter().find(|k| k["id"] == id.0).unwrap();
|
|
assert_eq!(node["overflow"], false);
|
|
assert_eq!(node["fit"], "shrinkText");
|
|
assert!(node["fitScale"].as_f64().unwrap() < 1.0);
|
|
let point = kids.iter().find(|k| k["id"] != id.0).unwrap();
|
|
assert!(point.get("overflow").is_none() && point.get("fitScale").is_none(), "point type never overflows");
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
s.execute("text.areaOptions", &json!({"fit": "none"})).unwrap();
|
|
let doc = s.execute("document.inspect", &json!({})).unwrap();
|
|
let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id.0).unwrap().clone();
|
|
assert_eq!((node["overflow"].as_bool(), node["fitScale"].as_f64(), node["fit"].as_str()), (Some(true), Some(1.0), Some("none")));
|
|
}
|
|
|
|
#[test]
|
|
fn auto_height_follows_edits_in_the_same_undo_step() {
|
|
let mut s = session();
|
|
let id = area(&mut s, 200.0, 300.0, "One line", json!({"fit": "autoHeight"}));
|
|
let one = frame(&s, id);
|
|
assert!(one.height() < 30.0, "the frame fits one line: {one:?}");
|
|
assert_eq!((one.x0, one.y0, one.width()), (0.0, 0.0, 200.0), "top and width stay");
|
|
assert!(!overflows(&s, id));
|
|
let n = undo_len(&s);
|
|
s.execute("text.editRange", &json!({"id": id.0, "start": 8, "insert": format!(" {STORY}")})).unwrap();
|
|
assert_eq!(undo_len(&s), n + 1);
|
|
let grown = frame(&s, id);
|
|
assert!(grown.height() > one.height() * 2.0, "{grown:?}");
|
|
assert!(!overflows(&s, id), "the grown frame holds the text");
|
|
// Tight: a point less overflows.
|
|
let mut t = text(&s, id);
|
|
t.area.fit = AreaFit::None;
|
|
t.kind = TextKind::Area {
|
|
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 200.0, grown.height() - 1.0), 0.1)),
|
|
};
|
|
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
|
// Character changes resize it too.
|
|
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 3, "size": 40})).unwrap();
|
|
assert!(frame(&s, id).height() > grown.height());
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frame(&s, id), grown);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frame(&s, id), one, "one undo restores the text and the frame");
|
|
assert_eq!(text(&s, id).plain_text(), "One line");
|
|
}
|
|
|
|
#[test]
|
|
fn auto_height_follows_typing_with_the_type_tool() {
|
|
let mut s = session();
|
|
s.prefs.auto_size_area_type = true;
|
|
// An empty frame (no placeholder text), so its first height is one line.
|
|
s.prefs.placeholder_text = false;
|
|
let v = ViewInfo::default();
|
|
s.select_tool("type", v).unwrap();
|
|
for (kind, (x, y)) in [(PointerKind::Down, (100.0, 100.0)), (PointerKind::Drag, (300.0, 400.0)), (PointerKind::Up, (300.0, 400.0))] {
|
|
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
|
}
|
|
let id = s.doc().unwrap().selection.objects[0];
|
|
assert_eq!(text(&s, id).area.fit, AreaFit::AutoHeight, "the preference gives new area type Auto Size");
|
|
let empty = frame(&s, id);
|
|
assert!(empty.height() < 30.0, "{empty:?}");
|
|
let n = undo_len(&s);
|
|
for word in STORY.split_inclusive(' ') {
|
|
s.tool_text(word, v).unwrap();
|
|
}
|
|
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
|
assert_eq!(text(&s, id).plain_text(), STORY);
|
|
assert_eq!(undo_len(&s), n + 1, "one typing step");
|
|
let typed = frame(&s, id);
|
|
assert!(typed.height() > empty.height() * 2.0, "{typed:?}");
|
|
assert!(!overflows(&s, id));
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frame(&s, id), empty);
|
|
assert_eq!(text(&s, id).plain_text(), "");
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
assert_eq!(frame(&s, id), typed);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_height_in_columns_and_by_hand() {
|
|
let mut s = session();
|
|
let id = area(&mut s, 300.0, 400.0, &STORY.repeat(3), json!({"fit": "autoHeight"}));
|
|
let one = frame(&s, id).height();
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
// Height is ignored while Auto Size is on.
|
|
let r = s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 12, "height": 500})).unwrap();
|
|
let two = r["height"].as_f64().unwrap();
|
|
assert!(two < 500.0 && two > one * 0.5, "{one} → {two}");
|
|
assert_eq!(r["overflow"], false);
|
|
assert_eq!(r["fit"], "autoHeight");
|
|
// The least height the two columns fit: a point less overflows, and both columns are used.
|
|
let mut t = text(&s, id);
|
|
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
|
assert!(lay.glyphs.iter().any(|g| g.origin.x > 160.0), "text flows into the second column");
|
|
t.area.fit = AreaFit::None;
|
|
t.kind = TextKind::Area {
|
|
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 300.0, two - 1.0), 0.1)),
|
|
};
|
|
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
|
// Dragging the bottom edge sets the height by hand: Auto Size goes off.
|
|
let b = frame(&s, id);
|
|
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]], "dx": 0, "dy": 50})).unwrap();
|
|
assert_eq!(text(&s, id).area.fit, AreaFit::None);
|
|
assert!((frame(&s, id).height() - (b.height() + 50.0)).abs() < 1e-6);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
// A new width keeps it: the height follows the reflow.
|
|
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 1], [0, 2]], "dx": 100, "dy": 0})).unwrap();
|
|
assert_eq!(text(&s, id).area.fit, AreaFit::AutoHeight);
|
|
assert!(frame(&s, id).height() < b.height());
|
|
// Point type has no frame to size.
|
|
let p = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
|
|
s.execute("select.set", &json!({"ids": [p]})).unwrap();
|
|
assert!(s.execute("text.areaOptions", &json!({"fit": "autoHeight"})).is_err(), "point type has no area options");
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_round_trips_through_the_native_format() {
|
|
let mut s = session();
|
|
let id = area(&mut s, 120.0, 30.0, STORY, json!({"fit": "shrinkText", "fitMinPercent": 35}));
|
|
let doc = s.doc().unwrap().doc.clone();
|
|
let bytes = vectorcraft_format::save(&doc, false);
|
|
let back = vectorcraft_format::load(&bytes).unwrap();
|
|
let NodeKind::Text(t) = &back.node(id).unwrap().kind else { panic!() };
|
|
assert_eq!(t.area.fit, AreaFit::ShrinkText { min_percent: 35.0 });
|
|
}
|
|
|
|
#[test]
|
|
fn auto_height_follows_the_leading_model_and_character_alignment() {
|
|
let mut s = session();
|
|
let id = area(&mut s, 200.0, 300.0, STORY, json!({"fit": "autoHeight"}));
|
|
let roman = frame(&s, id);
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
// Top-to-Top leading: the first line moves up to touch the frame, the frame follows.
|
|
s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap();
|
|
let em = frame(&s, id);
|
|
assert_ne!(em.height(), roman.height());
|
|
assert!(!overflows(&s, id), "the frame holds the text under emBoxTop");
|
|
// Mixed sizes aligned on the em box centre still fit exactly.
|
|
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 3, "size": 30})).unwrap();
|
|
s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap();
|
|
let mixed = frame(&s, id);
|
|
assert!(mixed.height() > em.height());
|
|
assert!(!overflows(&s, id));
|
|
let mut t = text(&s, id);
|
|
t.area.fit = AreaFit::None;
|
|
t.kind = TextKind::Area {
|
|
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 200.0, mixed.height() - 1.0), 0.1)),
|
|
};
|
|
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "tight");
|
|
}
|
|
|
|
/// #1471: the commands that make type or change what it holds say which text no longer fits, so
|
|
/// the call that pushed it out of its frame doesn't read as plain success.
|
|
#[test]
|
|
fn making_and_restyling_type_reports_its_overflow() {
|
|
let mut s = session();
|
|
// A headline in a frame lower than its line draws nothing.
|
|
let low = s.execute("text.create", &json!({"x": 0, "y": 20, "text": "NEON DRIVE", "size": 60, "area": {"width": 400, "height": 60}})).unwrap();
|
|
assert_eq!(low["overflow"], json!([low["id"]]), "{low}");
|
|
// One that fits says nothing, nor does point type.
|
|
let fits = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "NEON DRIVE", "size": 60, "area": {"width": 400, "height": 90}})).unwrap();
|
|
assert!(fits.get("overflow").is_none(), "{fits}");
|
|
let point = s.execute("text.create", &json!({"x": 0, "y": 400, "text": "NEON DRIVE", "size": 60})).unwrap();
|
|
assert!(point.get("overflow").is_none(), "{point}");
|
|
// Growing the type pushes it out; of the two restyled, only the area type is named.
|
|
let ids = json!([fits["id"], point["id"]]);
|
|
let grown = s.execute("text.setStyle", &json!({"ids": ids, "size": 96})).unwrap();
|
|
assert_eq!((&grown["ids"], &grown["overflow"]), (&ids, &json!([fits["id"]])), "{grown}");
|
|
let back = s.execute("text.setStyle", &json!({"ids": ids, "size": 60})).unwrap();
|
|
assert!(back.get("overflow").is_none(), "{back}");
|
|
// More words than the frame holds.
|
|
let long = s.execute("text.setText", &json!({"id": fits["id"], "text": STORY})).unwrap();
|
|
assert_eq!(long["overflow"], json!([fits["id"]]), "{long}");
|
|
// Type flowed into a path too small for it.
|
|
let frame = s.execute("shape.rectangle", &json!({"x": 500, "y": 300, "width": 60, "height": 20})).unwrap();
|
|
let inside = s.execute("text.createInPath", &json!({"path": frame["id"], "mode": "area", "text": STORY, "size": 12, "fit": "none"})).unwrap();
|
|
assert_eq!(inside["overflow"], json!([inside["id"]]), "{inside}");
|
|
}
|