Merge main into #1409

# Conflicts:
#	crates/engine/src/session/undo.rs
#	docs/roadmap.md
This commit is contained in:
bflatastic
2026-10-29 12:23:11 +07:00
31 changed files with 969 additions and 56 deletions
+21 -1
View File
@@ -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)));
}
}
+199
View File
@@ -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();
+2 -2
View File
@@ -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};
+4 -4
View File
@@ -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") {
+3
View File
@@ -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;
+30 -11
View File
@@ -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);
}
+13
View File
@@ -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();
+1 -1
View File
@@ -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);
+19 -11
View File
@@ -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]));
}
}
+14
View File
@@ -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.
+10
View File
@@ -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}");
}
+4 -1
View File
@@ -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;
}
}
+165
View File
@@ -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");
}