mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 21:36:46 -06:00
Merge main into #1409
# Conflicts: # crates/engine/src/session/undo.rs # docs/roadmap.md
This commit is contained in:
@@ -20,6 +20,13 @@ pub fn f64_of(v: &Value) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0-1 fraction: a `%` suffix or a number above 1 is a percentage ("1%" is 0.01, 50 is 0.5).
|
||||
pub fn fraction_of(v: &Value) -> Option<f64> {
|
||||
let f = f64_of(v)?;
|
||||
let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%'));
|
||||
Some(if percent || f > 1.0 { f / 100.0 } else { f })
|
||||
}
|
||||
|
||||
pub fn num(p: &Value, key: &str) -> Option<f64> {
|
||||
get(p, key).and_then(f64_of)
|
||||
}
|
||||
@@ -167,7 +174,7 @@ pub fn color_of(v: &Value) -> Option<Color> {
|
||||
Value::String(s) => Color::parse_css(s),
|
||||
Value::Object(_) => serde_json::from_value::<Color>(v.clone()).ok().map(Color::sanitized),
|
||||
Value::Array(a) => {
|
||||
let c: Vec<f32> = a.iter().filter_map(|x| x.as_f64()).map(|f| f as f32).collect();
|
||||
let c = a.iter().map(|x| x.as_f64().map(|f| f as f32)).collect::<Option<Vec<f32>>>()?;
|
||||
match c.as_slice() {
|
||||
[r, g, b] => Some(Color::new(*r, *g, *b, 1.0)),
|
||||
[r, g, b, a] => Some(Color::new(*r, *g, *b, *a)),
|
||||
@@ -278,3 +285,16 @@ pub fn parse<T: serde::de::DeserializeOwned>(cmd: &str, p: &Value, key: &str) ->
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_of_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn color_of_rejects_non_numeric_array_entries() {
|
||||
assert_eq!(color_of(&json!([1, "x", 0, 0])), None);
|
||||
assert_eq!(color_of(&json!([1, 0, 0, "x"])), None);
|
||||
assert_eq!(color_of(&json!([1, 0, 0, 0.5])), Some(Color::new(1.0, 0.0, 0.0, 0.5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,90 @@ fn failed_group_reopens_documents_closed_inside_it() {
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_restores_each_tabs_history_after_undo_close_and_open() {
|
||||
let mut s = Session::new();
|
||||
let mut originals = Vec::new();
|
||||
for (index, name) in ["First", "Second"].into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
s.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
let created = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": format!("{name} shape"),
|
||||
"commands": [{"command": "shape.rectangle", "params": {
|
||||
"x": index * 20, "y": 0, "width": 10, "height": 10, "props": {"name": name}
|
||||
}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let original = id(&created[0]);
|
||||
s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": format!("{name} name"),
|
||||
"commands": [{"command": "node.set", "params": {"id": original, "props": {"name": format!("{name} renamed")}}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
originals.push(original);
|
||||
}
|
||||
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
|
||||
let before_docs: Vec<_> = s.docs.iter().map(|ds| ds.doc.clone()).collect();
|
||||
let before_labels: Vec<_> = s
|
||||
.docs
|
||||
.iter()
|
||||
.map(|ds| {
|
||||
(ds.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), ds.history.redo.iter().map(|h| h.label.clone()).collect::<Vec<_>>())
|
||||
})
|
||||
.collect();
|
||||
assert_ne!(before_labels[0], before_labels[1]);
|
||||
let active = s.active;
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "doc.nextTab"},
|
||||
{"command": "edit.undo"},
|
||||
{"command": "file.close", "params": {"index": 0, "force": true}},
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
|
||||
assert_eq!(s.active, active);
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
for (index, ds) in s.docs.iter().enumerate() {
|
||||
assert_eq!(ds.doc, before_docs[index]);
|
||||
assert_eq!(ds.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), before_labels[index].0);
|
||||
assert_eq!(ds.history.redo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), before_labels[index].1);
|
||||
}
|
||||
// Travel through each restored tab's real history, including the pre-existing redo step.
|
||||
for _ in 0..2 {
|
||||
let index = s.active;
|
||||
let original = originals[index];
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
assert_eq!(s.doc().unwrap(), before_docs[index].as_ref());
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
let name = &before_docs[index].node(original).unwrap().name;
|
||||
assert_eq!(s.doc().unwrap().node(original).unwrap().name, format!("{name} renamed"));
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert_eq!(s.doc().unwrap(), before_docs[index].as_ref());
|
||||
s.execute("doc.nextTab", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
|
||||
let mut s = Session::new();
|
||||
@@ -87,6 +171,121 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state()
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_undo_steps_it_undid() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0), "the undo step survives the failed batch");
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_history_after_undo_and_redo() {
|
||||
let mut s = Session::new();
|
||||
let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let second = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
assert!(s.undo().unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
let before_labels = s.undo_labels(10);
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "edit.redo"},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
assert_eq!(s.undo_labels(10), before_labels);
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(first));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(first));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_outer_group_keeps_the_undo_steps_a_nested_batch_undid() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "command.batch", "params": {"commands": [{"command": "edit.undo"}]}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_restores_the_small_history_limit() {
|
||||
let mut s = Session::new();
|
||||
s.ds_mut().unwrap().history.limit = 1;
|
||||
let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 0, "width": 10, "height": 10}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert_eq!(s.ds().unwrap().history.limit, 1);
|
||||
let second = id(&s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
assert_eq!(s.undo_depth(), (1, 0), "new edits still respect the limit");
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(first));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(!s.undo().unwrap(), "the oldest step was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_redo_steps() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{
|
||||
blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string,
|
||||
targets,
|
||||
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
|
||||
string, targets,
|
||||
};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index,
|
||||
paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
|
||||
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, fraction_of, get, json, num, ok,
|
||||
opt_index, paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
|
||||
};
|
||||
|
||||
pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -312,8 +312,8 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
|
||||
slot.color_alpha = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
if let Some(o) = get(p, "opacity").and_then(fraction_of) {
|
||||
slot.opacity = o.clamp(0.0, 1.0) as f32;
|
||||
slot.color_alpha = 1.0;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
|
||||
@@ -432,5 +432,8 @@ mod rest_import_tests;
|
||||
#[cfg(test)]
|
||||
mod staging_write_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumbnail_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod zip_cap_tests;
|
||||
|
||||
@@ -3,19 +3,38 @@ use super::load::{ext, file_stem, write};
|
||||
use super::open::remember;
|
||||
use super::*;
|
||||
|
||||
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
|
||||
/// Thumbnail PNG of the chosen frame, or the first page's content (file browsers / home screen).
|
||||
pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
let page = doc.first_page()?;
|
||||
let b = doc.page_content_bounds(page)?;
|
||||
let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);
|
||||
let view = figcraft_render::View {
|
||||
origin: (b.x0, b.y0),
|
||||
scale,
|
||||
width: (b.width() * scale).ceil().max(1.0) as u32,
|
||||
height: (b.height() * scale).ceil().max(1.0) as u32,
|
||||
let render = |page, b: figcraft_geom::Rect, solo| {
|
||||
let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);
|
||||
let view = figcraft_render::View {
|
||||
origin: (b.x0, b.y0),
|
||||
scale,
|
||||
width: (b.width() * scale).ceil().max(1.0) as u32,
|
||||
height: (b.height() * scale).ceil().max(1.0) as u32,
|
||||
};
|
||||
let opts = figcraft_render::Options { solo, ..Default::default() };
|
||||
let pix = figcraft_render::render_view(doc, page, &view, &opts)?;
|
||||
figcraft_render::encode_png(&pix).ok()
|
||||
};
|
||||
let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?;
|
||||
figcraft_render::encode_png(&pix).ok()
|
||||
doc.ids()
|
||||
.find_map(|id| {
|
||||
let n = doc.node(id)?;
|
||||
if !n.kind.is_frame_like() || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") {
|
||||
return None;
|
||||
}
|
||||
// Hidden library-store pages are not document pages.
|
||||
let page = doc.page_of(id).filter(|p| doc.pages.contains(p))?;
|
||||
let b = figcraft_render::export_bounds(doc, id)?;
|
||||
if ![b.x0, b.y0, b.x1, b.y1, b.width(), b.height()].into_iter().all(f64::is_finite) || b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
render(page, b, Some(id))
|
||||
})
|
||||
.or_else(|| {
|
||||
let page = doc.first_page()?;
|
||||
render(page, doc.page_content_bounds(page)?, None)
|
||||
})
|
||||
}
|
||||
|
||||
/// The `.figcraft` file for a document: content, thumbnail and version history.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_geom::Affine;
|
||||
|
||||
const RED: Color = Color::from_rgb8_const(255, 0, 0);
|
||||
const GREEN: Color = Color::from_rgb8_const(0, 255, 0);
|
||||
const BLUE: Color = Color::from_rgb8_const(0, 0, 255);
|
||||
|
||||
fn scene() -> (Session, NodeId, NodeId) {
|
||||
let mut doc = Document::new("Thumbnail");
|
||||
let page = doc.first_page().unwrap();
|
||||
let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap();
|
||||
let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap();
|
||||
doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)];
|
||||
doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)];
|
||||
(Session::with_document(doc), cover, wide)
|
||||
}
|
||||
|
||||
fn choose(s: &mut Session, id: NodeId) {
|
||||
s.execute("object.setThumbnail", &json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
fn picture(doc: &Document) -> image::RgbaImage {
|
||||
image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_frame_draws_its_children_without_siblings_or_document_changes() {
|
||||
let (mut s, cover, _) = scene();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.first_page().unwrap();
|
||||
let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap();
|
||||
doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)];
|
||||
// Cropping the whole page to Cover's bounds would still draw this overlapping sibling.
|
||||
let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap();
|
||||
doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)];
|
||||
choose(&mut s, cover);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (200, 200));
|
||||
assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]);
|
||||
assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]);
|
||||
assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap();
|
||||
assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_thumbnail_choice_keeps_the_first_page_content_bounds() {
|
||||
let (s, _, _) = scene();
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (400, 31));
|
||||
assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]);
|
||||
assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_flags_and_non_frames_keep_the_default_thumbnail() {
|
||||
let (mut s, cover, wide) = scene();
|
||||
let default = thumbnail(s.doc().unwrap()).unwrap();
|
||||
for value in ["false", "TRUE", ""] {
|
||||
s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into());
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}");
|
||||
}
|
||||
choose(&mut s, wide);
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle;
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails");
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() };
|
||||
assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() {
|
||||
let (mut s, cover, _) = scene();
|
||||
choose(&mut s, cover);
|
||||
s.doc_mut().unwrap().remove(cover).unwrap();
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (400, 67));
|
||||
assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_thumbnail_ranges_and_pages_fall_back() {
|
||||
let (mut s, _, _) = scene();
|
||||
let default = thumbnail(s.doc().unwrap()).unwrap();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.add_page("Invalid cover");
|
||||
let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
choose(&mut s, cover);
|
||||
for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] {
|
||||
let n = s.doc_mut().unwrap().get_mut(cover).unwrap();
|
||||
n.width = width;
|
||||
n.height = height;
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}");
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let n = doc.get_mut(cover).unwrap();
|
||||
n.width = 100.0;
|
||||
n.height = 100.0;
|
||||
n.parent = Some(NodeId(u64::MAX));
|
||||
assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page");
|
||||
doc.get_mut(cover).unwrap().parent = Some(page);
|
||||
doc.pages.retain(|p| *p != page);
|
||||
assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_frame_on_another_page_uses_its_page_background() {
|
||||
let (mut s, _, _) = scene();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.add_page("Cover page");
|
||||
if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind {
|
||||
*background = GREEN;
|
||||
}
|
||||
let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap();
|
||||
let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap();
|
||||
doc.get_mut(cover).unwrap().fills.clear();
|
||||
let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap();
|
||||
doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)];
|
||||
choose(&mut s, cover);
|
||||
let png = picture(s.doc().unwrap());
|
||||
assert_eq!(png.dimensions(), (160, 80));
|
||||
assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]);
|
||||
assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() {
|
||||
let (mut s, cover, _) = scene();
|
||||
choose(&mut s, cover);
|
||||
for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] {
|
||||
let n = s.doc_mut().unwrap().get_mut(cover).unwrap();
|
||||
n.width = width;
|
||||
n.height = height;
|
||||
assert_eq!(picture(s.doc().unwrap()).dimensions(), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() {
|
||||
let (mut s, cover, _) = scene();
|
||||
choose(&mut s, cover);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let expected = picture(s.doc().unwrap());
|
||||
let native = file_bytes(s.ds().unwrap()).unwrap();
|
||||
let (fig, _) = super::export::fig_bytes(s.doc().unwrap()).unwrap();
|
||||
for bytes in [native, fig] {
|
||||
let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap();
|
||||
let actual = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
assert_eq!(actual.dimensions(), (200, 200));
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
@@ -181,6 +181,19 @@ mod tests {
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_axis_padding_is_refused_and_keeps_padding() {
|
||||
let mut s = Session::new();
|
||||
let (f, _, _) = al_frame(&mut s);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 16}}));
|
||||
for key in ["paddingHorizontal", "paddingVertical"] {
|
||||
for junk in [json!("abc"), json!({"a": 1}), json!([1, 2])] {
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {key: junk}})).is_err(), "{key}");
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [16.0; 4], "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_alignment_lines_up_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
spec!("page.duplicate", "Duplicate page", "{id?}", true, |s, p| {
|
||||
let src = id(p, "id").unwrap_or(s.page()?);
|
||||
let src = page_target(s, "page.duplicate", p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let name = format!("Copy of {}", doc.get(src)?.name);
|
||||
let np = doc.add_page(&name);
|
||||
|
||||
@@ -9,14 +9,14 @@ use figcraft_doc::{
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::params::{bool_of, color_of, f64_of, get, paints_of};
|
||||
use crate::params::{bool_of, color_of, f64_of, fraction_of, get, paints_of};
|
||||
use crate::{Result, bad};
|
||||
|
||||
/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left).
|
||||
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
@@ -169,7 +169,7 @@ pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
|
||||
fn four(v: &Value) -> Option<[f64; 4]> {
|
||||
match v {
|
||||
Value::Array(a) => {
|
||||
let f: Vec<f64> = a.iter().filter_map(f64_of).collect();
|
||||
let f: Vec<f64> = a.iter().map(f64_of).collect::<Option<_>>()?;
|
||||
match f.as_slice() {
|
||||
[x] => Some([*x; 4]),
|
||||
[a, b] => Some([*a, *b, *a, *b]),
|
||||
@@ -487,8 +487,7 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
"visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
|
||||
"locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
|
||||
"opacity" => {
|
||||
let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
|
||||
let o = if o > 1.0 { o / 100.0 } else { o };
|
||||
let o = fraction_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
|
||||
doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
"blend" | "blendMode" => {
|
||||
@@ -650,13 +649,13 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
|
||||
}
|
||||
}
|
||||
"paddingHorizontal" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[1] = val;
|
||||
l.padding[3] = val;
|
||||
}
|
||||
"paddingVertical" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[0] = val;
|
||||
l.padding[2] = val;
|
||||
@@ -888,20 +887,20 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
}
|
||||
}
|
||||
"innerRadius" | "ratio" => {
|
||||
let r = f64_of(v).unwrap_or(0.38);
|
||||
let r = if r > 1.0 { r / 100.0 } else { r };
|
||||
let r = fraction_of(v).unwrap_or(0.38);
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0),
|
||||
_ => return Err(bad(cmd, "innerRadius applies to stars")),
|
||||
}
|
||||
}
|
||||
"arcStart" | "arcSweep" | "arcInner" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let val = if k == "arcInner" { fraction_of(v) } else { f64_of(v) };
|
||||
let val = val.ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => match k {
|
||||
"arcStart" => *start = val.to_radians(),
|
||||
"arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(),
|
||||
_ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
|
||||
_ => *inner = val.clamp(0.0, 0.99),
|
||||
},
|
||||
_ => return Err(bad(cmd, "arc applies to ellipses")),
|
||||
}
|
||||
@@ -1370,4 +1369,13 @@ mod tests {
|
||||
}
|
||||
assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_rejects_non_numeric_entries() {
|
||||
assert_eq!(four(&json!([10, "abc"])), None);
|
||||
assert_eq!(four(&json!([10, "abc", null, 20])), None);
|
||||
assert_eq!(four(&json!([10, 20, 30])), None);
|
||||
assert_eq!(four(&json!([1, 2, 3, 4])), Some([1.0, 2.0, 3.0, 4.0]));
|
||||
assert_eq!(four(&json!([1, 2])), Some([1.0, 2.0, 1.0, 2.0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,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());
|
||||
@@ -158,6 +163,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.
|
||||
|
||||
@@ -61,3 +61,13 @@ fn page_commands_still_work_on_pages() {
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_duplicate_rejects_a_frame() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let pages = s.doc().unwrap().pages.clone();
|
||||
let err = s.execute("page.duplicate", &json!({"id": f})).expect_err("duplicate refuses a layer");
|
||||
assert!(err.to_string().contains("is not a page"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().pages, pages, "no page was added");
|
||||
}
|
||||
|
||||
@@ -475,3 +475,29 @@ fn page_navigation_skips_dividers() {
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_percent_strings_always_divide_by_100() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "R", 0.0, 0.0, None);
|
||||
for (v, want) in [(json!("1%"), 0.01), (json!("0.5%"), 0.005), (json!("50%"), 0.5), (json!(1), 1.0), (json!(50), 0.5), (json!(0.25), 0.25)] {
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"opacity": v.clone()}}));
|
||||
let o = s.doc().unwrap().node(r).unwrap().opacity;
|
||||
assert!((o - want).abs() < 1e-6, "opacity {v} gave {o}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_opacity_star_ratio_and_arc_ratio_read_a_percent_suffix() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "R", 0.0, 0.0, None);
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "color": "#ff0000", "opacity": "1%"}));
|
||||
let fill = s.doc().unwrap().node(r).unwrap().fills[0].opacity;
|
||||
assert!((fill - 0.01).abs() < 1e-6, "fill opacity \"1%\" gave {fill}");
|
||||
let star = id(&ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"innerRadius": "1%"}})));
|
||||
let figcraft_doc::NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") };
|
||||
assert!((inner_ratio - 0.01).abs() < 1e-9, "innerRadius \"1%\" gave {inner_ratio}");
|
||||
let arc = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"arcInner": "1%"}})));
|
||||
let figcraft_doc::NodeKind::Ellipse { inner, .. } = s.doc().unwrap().node(arc).unwrap().kind else { panic!("ellipse") };
|
||||
assert!((inner - 0.01).abs() < 1e-9, "arcInner \"1%\" gave {inner}");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ struct GroupDocCheckpoint {
|
||||
selection: Vec<NodeId>,
|
||||
page: NodeId,
|
||||
undo_len: usize,
|
||||
/// An undo inside pops undo steps; a failed group has to bring them back.
|
||||
undo: Vec<HistoryEntry>,
|
||||
redo: Vec<HistoryEntry>,
|
||||
limit: usize,
|
||||
}
|
||||
@@ -302,6 +304,7 @@ impl Session {
|
||||
selection: ds.selection.clone(),
|
||||
page: ds.page,
|
||||
undo_len: ds.history.undo.len(),
|
||||
undo: ds.history.undo.clone(),
|
||||
// An edit inside clears redo; a failed group has to bring it back.
|
||||
redo: ds.history.redo.clone(),
|
||||
limit: ds.history.limit,
|
||||
@@ -341,7 +344,7 @@ impl Session {
|
||||
ds.doc = saved.doc;
|
||||
ds.selection = saved.selection;
|
||||
ds.page = saved.page;
|
||||
ds.history.undo.truncate(saved.undo_len);
|
||||
ds.history.undo = saved.undo;
|
||||
ds.history.limit = saved.limit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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