mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
171
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049f97a4c3 | ||
|
|
62a291fcb8 | ||
|
|
7459cc7f86 | ||
|
|
9f935718fe | ||
|
|
1c863aa9f5 | ||
|
|
891f0acb3c | ||
|
|
55c05338a1 | ||
|
|
4e3fd82bd1 | ||
|
|
41b08a2393 | ||
|
|
730e377418 | ||
|
|
175fdfa428 | ||
|
|
d60a27b675 | ||
|
|
fd6a7e0ba3 | ||
|
|
db8bf6ba33 | ||
|
|
4d9cde8fee | ||
|
|
f8458b70a8 | ||
|
|
0a84dee777 | ||
|
|
46316c9895 | ||
|
|
0e1ccb55d2 | ||
|
|
ec8a506353 | ||
|
|
eb741a362d | ||
|
|
97e422226f | ||
|
|
8ba09001cd | ||
|
|
ecad646330 | ||
|
|
87cb799783 | ||
|
|
466fd249fe | ||
|
|
a4ba67ac22 | ||
|
|
99b6495b7d | ||
|
|
6eb9f38487 | ||
|
|
bf4d9e0f6f | ||
|
|
9418e2c8bb | ||
|
|
c4c88900c4 | ||
|
|
aadaa2d975 | ||
|
|
580eee3572 | ||
|
|
e359d856f2 | ||
|
|
e08c1c598f | ||
|
|
4df3702dbb | ||
|
|
b7c692171d | ||
|
|
d95aef1d0c | ||
|
|
887e374c58 | ||
|
|
34dcf852d5 | ||
|
|
5e7eb51e46 | ||
|
|
d40fd80be7 | ||
|
|
1c5b4d8450 | ||
|
|
ef69a7f8e9 | ||
|
|
a1849aae15 | ||
|
|
1e25e239c2 | ||
|
|
7bda68876b | ||
|
|
362bdce73b | ||
|
|
5e99e71728 | ||
|
|
09e85240ea | ||
|
|
7998845515 | ||
|
|
e8eca728fe | ||
|
|
438d468c76 | ||
|
|
07130947f9 | ||
|
|
16e26b69ff | ||
|
|
cd5c70a244 | ||
|
|
e6240f6229 | ||
|
|
1498c0ea52 | ||
|
|
67cf5ecfd5 | ||
|
|
0c3c00db99 | ||
|
|
7988e48b5d | ||
|
|
0105724108 | ||
|
|
2894195ab3 | ||
|
|
02156c0edc | ||
|
|
06844dc703 | ||
|
|
2d1a7c973d | ||
|
|
77c9ccd649 | ||
|
|
95b32053d5 | ||
|
|
b1760ff098 | ||
|
|
b0aa597787 | ||
|
|
dcb36e376f | ||
|
|
139195cb0c | ||
|
|
991cb604c2 | ||
|
|
1ef42e6504 | ||
|
|
d7de56f046 | ||
|
|
4bc63d506c | ||
|
|
567110f063 | ||
|
|
ffde863e92 | ||
|
|
e2b567d5b3 | ||
|
|
77b6f1fc12 | ||
|
|
97b10b637d | ||
|
|
7da4cf5e31 | ||
|
|
a8416b27c1 | ||
|
|
9552e530be | ||
|
|
fe5a22e468 | ||
|
|
59fe9b5ea8 | ||
|
|
862d73bf9f | ||
|
|
9dcfe4ecbc | ||
|
|
2fe7a9449a | ||
|
|
916ee3276c | ||
|
|
97f9c2fc5d | ||
|
|
4a2d19f0db | ||
|
|
a7bcc6d6ff | ||
|
|
f4d53a659b | ||
|
|
3074db1cbb | ||
|
|
4f9023d80f | ||
|
|
825bb708df | ||
|
|
55db465d8e | ||
|
|
e9e2d87405 | ||
|
|
44128f7e97 | ||
|
|
782c9757f3 | ||
|
|
5454bdc244 | ||
|
|
843b00b22f | ||
|
|
9fa99f8594 | ||
|
|
85d97a831c | ||
|
|
7d0f9d7518 | ||
|
|
4588832c80 | ||
|
|
bc5b474293 | ||
|
|
18e3070d4c | ||
|
|
3062c6c4d6 | ||
|
|
4376f659ac | ||
|
|
0ba8e30fbc | ||
|
|
fdf397d7dc | ||
|
|
d1fcf69b7b | ||
|
|
49010614fd | ||
|
|
778b8c6807 | ||
|
|
1593b163fb | ||
|
|
102429e6b5 | ||
|
|
6ce65b24f4 | ||
|
|
a4f4869443 | ||
|
|
a57976e88f | ||
|
|
3c28f605d1 | ||
|
|
76a67e7ee5 | ||
|
|
330b237c9c | ||
|
|
7689af50d6 | ||
|
|
d769320d45 | ||
|
|
01c0b38669 | ||
|
|
b61122ccca | ||
|
|
524b538908 | ||
|
|
72b826d65b | ||
|
|
0d125374d3 | ||
|
|
8199623267 | ||
|
|
6ea957ec5d | ||
|
|
b29cc01d55 | ||
|
|
d0285d236e | ||
|
|
908906f3f4 | ||
|
|
2d78f40e60 | ||
|
|
f6437548ba | ||
|
|
fbe746a776 | ||
|
|
9853ffce61 | ||
|
|
2f4d3894b5 | ||
|
|
bf506712ba | ||
|
|
7d6a3ce137 | ||
|
|
ac7c91fc9c | ||
|
|
34565964b0 | ||
|
|
b3b6b14eb4 | ||
|
|
be56ca2836 | ||
|
|
9687c0ffc0 | ||
|
|
8b0649da3a | ||
|
|
3293a12542 | ||
|
|
42e6f267b8 | ||
|
|
831b8fed43 | ||
|
|
72a3c8522f | ||
|
|
ec9b1197c9 | ||
|
|
43e6cba2ef | ||
|
|
8e56820168 | ||
|
|
376d524a57 | ||
|
|
61bc027072 | ||
|
|
4cf1da167c | ||
|
|
dee13aefc5 | ||
|
|
7dd71bc2da | ||
|
|
ef10edfe29 | ||
|
|
c3330c4260 | ||
|
|
e4b4fb0cac | ||
|
|
4f9cbf702f | ||
|
|
6a2eeec928 | ||
|
|
c6fdf9bd4c | ||
|
|
e8a30df944 | ||
|
|
ee2640d18e | ||
|
|
9eadb37622 |
No files matched your search
+5
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
@@ -313,6 +313,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
|
||||
|
||||
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
|
||||
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
|
||||
match name {
|
||||
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||||
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||||
Some(n) => doc
|
||||
.pages
|
||||
.iter()
|
||||
@@ -380,6 +380,22 @@ mod tests {
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_page_skips_leading_divider() {
|
||||
let mut doc = Document::new("divider");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
|
||||
let divider = doc.add_page("- Divider");
|
||||
doc.pages.retain(|p| *p != divider);
|
||||
doc.pages.insert(0, divider);
|
||||
assert!(doc.is_divider(divider));
|
||||
assert_eq!(page_named(&doc, None).unwrap(), page);
|
||||
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
|
||||
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
|
||||
assert_eq!((pm.width(), pm.height()), (40, 30));
|
||||
assert!(render_page(&doc, divider, 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
|
||||
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
|
||||
);
|
||||
let doc = s.doc().map_err(failed)?.clone();
|
||||
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
|
||||
table.put(
|
||||
col,
|
||||
"switch a card's variant (nested overrides kept)",
|
||||
mean(5, |i| {
|
||||
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
|
||||
}),
|
||||
);
|
||||
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
|
||||
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
|
||||
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
|
||||
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
|
||||
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
|
||||
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
|
||||
// fonts), so ask the hit test the swap uses.
|
||||
let now = s.doc().map_err(failed)?.clone();
|
||||
let page = s.page().map_err(failed)?;
|
||||
let b = std::iter::once(bglyph)
|
||||
.chain(inst.iter().filter_map(|i| {
|
||||
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
|
||||
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
|
||||
}))
|
||||
.filter_map(|g| now.world_bounds(g))
|
||||
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
|
||||
.ok_or_else(|| failed("no button glyph to drop on"))?;
|
||||
let other = ds.icons.get(5).copied().unwrap_or(icon0);
|
||||
table.put(
|
||||
col,
|
||||
"Alt-drop swap onto an instance",
|
||||
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
|
||||
);
|
||||
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
|
||||
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
|
||||
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
|
||||
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
|
||||
table.put(
|
||||
col,
|
||||
"switch a card's variant (nested overrides kept)",
|
||||
mean(5, |i| {
|
||||
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
|
||||
}
|
||||
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
|
||||
if let Some(nav) = nav {
|
||||
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
|
||||
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
|
||||
} else {
|
||||
table.put(col, "reset all changes on a nav", f64::NAN);
|
||||
table.put(col, "detach a nav", f64::NAN);
|
||||
}
|
||||
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
|
||||
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
|
||||
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
|
||||
// fonts), so ask the hit test the swap uses.
|
||||
let now = s.doc().map_err(failed)?.clone();
|
||||
let page = s.page().map_err(failed)?;
|
||||
let drop_at = std::iter::once(bglyph)
|
||||
.chain(inst.iter().filter_map(|i| {
|
||||
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
|
||||
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
|
||||
}))
|
||||
.filter_map(|g| now.world_bounds(g))
|
||||
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
|
||||
let other = ds.icons.get(5).copied().unwrap_or(icon0);
|
||||
table.put(
|
||||
col,
|
||||
"Alt-drop swap onto an instance",
|
||||
match drop_at {
|
||||
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
|
||||
None => f64::NAN,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
table.put(col, "push a button's changes to its main", f64::NAN);
|
||||
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
|
||||
}
|
||||
if let Some(nav) = nav {
|
||||
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
|
||||
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
|
||||
} else {
|
||||
table.put(col, "copy + paste a nav", f64::NAN);
|
||||
}
|
||||
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
|
||||
table.put(col, "redo", mean(10, |_| _ = s.redo()));
|
||||
|
||||
// As a library: publish it, use it from another file, publish a change, accept the update.
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
|
||||
// One directory per run, so concurrent runs in a process don't delete each other's files.
|
||||
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).map_err(failed)?;
|
||||
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
|
||||
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
|
||||
@@ -533,6 +555,14 @@ mod tests {
|
||||
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_components_with_a_single_instance() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
super::measure_components(&mut t, 0, 1).unwrap();
|
||||
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
|
||||
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_a_small_document() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
|
||||
@@ -347,14 +347,14 @@ impl Store {
|
||||
let key = load_key(root)?;
|
||||
let dir = root.join(FILES);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
let (directory, problems) = Directory::open(root, cfg.directory);
|
||||
let (directory, startup_problems) = Directory::open(root, cfg.directory);
|
||||
let abuse = Abuse::new(cfg.hosting.abuse);
|
||||
let mut store = Store {
|
||||
root: root.to_path_buf(),
|
||||
cfg,
|
||||
key,
|
||||
files: BTreeMap::new(),
|
||||
problems,
|
||||
problems: Vec::new(),
|
||||
directory,
|
||||
abuse,
|
||||
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
|
||||
@@ -368,6 +368,9 @@ impl Store {
|
||||
free: None,
|
||||
_lock: lock,
|
||||
};
|
||||
for problem in startup_problems {
|
||||
store.note(problem);
|
||||
}
|
||||
store.scan(&dir);
|
||||
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
|
||||
store.reports = store.read_list(REPORTS);
|
||||
@@ -385,7 +388,7 @@ impl Store {
|
||||
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
self.problems.push(e);
|
||||
self.note(e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -394,7 +397,7 @@ impl Store {
|
||||
fn scan(&mut self, dir: &Path) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
|
||||
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
|
||||
};
|
||||
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
ids.sort();
|
||||
@@ -403,18 +406,18 @@ impl Store {
|
||||
continue;
|
||||
}
|
||||
if !valid_id(&id) {
|
||||
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
self.note(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
continue;
|
||||
}
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
continue;
|
||||
}
|
||||
match self.check(&path) {
|
||||
Ok(entry) => {
|
||||
self.files.insert(id, entry);
|
||||
}
|
||||
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
|
||||
Err(why) => self.note(format!("skipped file {id}: {why}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
use figcraft_server::config::Config;
|
||||
use figcraft_server::store::{FILES, Store};
|
||||
|
||||
#[test]
|
||||
fn startup_problem_retention() {
|
||||
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
|
||||
assert!(!root.exists());
|
||||
let result = (|| -> Result<(), String> {
|
||||
for count in [3u32, 200, 201] {
|
||||
let data = root.join(count.to_string());
|
||||
for i in 0..count {
|
||||
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
|
||||
cfg.check()?;
|
||||
let store = Store::open(&data, cfg.store())?;
|
||||
let expected = (count as usize).min(200);
|
||||
let problems = store.problems().to_vec();
|
||||
let no_loaded_files = store.is_empty();
|
||||
drop(store);
|
||||
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
|
||||
return Err("unexpected diagnostic or loaded file".into());
|
||||
}
|
||||
if problems.len() != expected {
|
||||
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
|
||||
}
|
||||
// The latest problems stay, in scan order: the first folder's is the one dropped.
|
||||
let first_kept = count as usize - expected;
|
||||
for (n, p) in problems.iter().enumerate() {
|
||||
let id = format!("{:016x}", first_kept + n);
|
||||
if !p.contains(&id) {
|
||||
return Err(format!("problem {n} should be about {id}: {p}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(result.is_ok(), "{}", result.unwrap_err());
|
||||
}
|
||||
@@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
|
||||
Err(e) => {
|
||||
// A line that isn't JSON ends the connection: a web page can make the browser send
|
||||
// an HTTP request here, and its header lines must not let the JSON lines of its body
|
||||
// through as commands.
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
writeln!(out, "{reply}")?;
|
||||
}
|
||||
|
||||
@@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
@@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
|
||||
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
|
||||
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
client.write_all(http.as_bytes()).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
let (mut client, server) = pair();
|
||||
|
||||
@@ -38,4 +38,9 @@ real_name = "Justin John"
|
||||
[people.Losminek]
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.volkovskey]
|
||||
real_name = "Andrii Volkov"
|
||||
+12
-16
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
mod blend;
|
||||
mod hsx;
|
||||
mod icc;
|
||||
mod named;
|
||||
mod profile;
|
||||
pub use blend::BlendMode;
|
||||
pub use hsx::*;
|
||||
@@ -139,7 +140,13 @@ impl Color {
|
||||
Some(Self::from_rgba8(r, g, b, a))
|
||||
}
|
||||
|
||||
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
|
||||
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
|
||||
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
|
||||
pub fn from_hex_or_name(s: &str) -> Option<Self> {
|
||||
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
|
||||
}
|
||||
|
||||
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
|
||||
pub fn parse_css(s: &str) -> Option<Self> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.starts_with('#') {
|
||||
@@ -166,21 +173,10 @@ impl Color {
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
|
||||
}
|
||||
let named = match t.as_str() {
|
||||
"black" => Color::BLACK,
|
||||
"white" => Color::WHITE,
|
||||
"transparent" => Color::TRANSPARENT,
|
||||
"red" => Color::from_rgb8_const(255, 0, 0),
|
||||
"green" => Color::from_rgb8_const(0, 128, 0),
|
||||
"lime" => Color::from_rgb8_const(0, 255, 0),
|
||||
"blue" => Color::from_rgb8_const(0, 0, 255),
|
||||
"yellow" => Color::from_rgb8_const(255, 255, 0),
|
||||
"orange" => Color::from_rgb8_const(255, 165, 0),
|
||||
"purple" => Color::from_rgb8_const(128, 0, 128),
|
||||
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
|
||||
_ => return Self::from_hex(&t),
|
||||
};
|
||||
Some(named)
|
||||
if t == "transparent" {
|
||||
return Some(Color::TRANSPARENT);
|
||||
}
|
||||
Self::from_hex_or_name(&t)
|
||||
}
|
||||
|
||||
/// CSS `rgba(...)` / hex text for copy-as-CSS.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
|
||||
//! `FF0000`.
|
||||
|
||||
/// Every CSS named colour except `transparent`, sorted by name for binary search.
|
||||
const NAMED: [(&str, [u8; 3]); 148] = [
|
||||
("aliceblue", [240, 248, 255]),
|
||||
("antiquewhite", [250, 235, 215]),
|
||||
("aqua", [0, 255, 255]),
|
||||
("aquamarine", [127, 255, 212]),
|
||||
("azure", [240, 255, 255]),
|
||||
("beige", [245, 245, 220]),
|
||||
("bisque", [255, 228, 196]),
|
||||
("black", [0, 0, 0]),
|
||||
("blanchedalmond", [255, 235, 205]),
|
||||
("blue", [0, 0, 255]),
|
||||
("blueviolet", [138, 43, 226]),
|
||||
("brown", [165, 42, 42]),
|
||||
("burlywood", [222, 184, 135]),
|
||||
("cadetblue", [95, 158, 160]),
|
||||
("chartreuse", [127, 255, 0]),
|
||||
("chocolate", [210, 105, 30]),
|
||||
("coral", [255, 127, 80]),
|
||||
("cornflowerblue", [100, 149, 237]),
|
||||
("cornsilk", [255, 248, 220]),
|
||||
("crimson", [220, 20, 60]),
|
||||
("cyan", [0, 255, 255]),
|
||||
("darkblue", [0, 0, 139]),
|
||||
("darkcyan", [0, 139, 139]),
|
||||
("darkgoldenrod", [184, 134, 11]),
|
||||
("darkgray", [169, 169, 169]),
|
||||
("darkgreen", [0, 100, 0]),
|
||||
("darkgrey", [169, 169, 169]),
|
||||
("darkkhaki", [189, 183, 107]),
|
||||
("darkmagenta", [139, 0, 139]),
|
||||
("darkolivegreen", [85, 107, 47]),
|
||||
("darkorange", [255, 140, 0]),
|
||||
("darkorchid", [153, 50, 204]),
|
||||
("darkred", [139, 0, 0]),
|
||||
("darksalmon", [233, 150, 122]),
|
||||
("darkseagreen", [143, 188, 143]),
|
||||
("darkslateblue", [72, 61, 139]),
|
||||
("darkslategray", [47, 79, 79]),
|
||||
("darkslategrey", [47, 79, 79]),
|
||||
("darkturquoise", [0, 206, 209]),
|
||||
("darkviolet", [148, 0, 211]),
|
||||
("deeppink", [255, 20, 147]),
|
||||
("deepskyblue", [0, 191, 255]),
|
||||
("dimgray", [105, 105, 105]),
|
||||
("dimgrey", [105, 105, 105]),
|
||||
("dodgerblue", [30, 144, 255]),
|
||||
("firebrick", [178, 34, 34]),
|
||||
("floralwhite", [255, 250, 240]),
|
||||
("forestgreen", [34, 139, 34]),
|
||||
("fuchsia", [255, 0, 255]),
|
||||
("gainsboro", [220, 220, 220]),
|
||||
("ghostwhite", [248, 248, 255]),
|
||||
("gold", [255, 215, 0]),
|
||||
("goldenrod", [218, 165, 32]),
|
||||
("gray", [128, 128, 128]),
|
||||
("green", [0, 128, 0]),
|
||||
("greenyellow", [173, 255, 47]),
|
||||
("grey", [128, 128, 128]),
|
||||
("honeydew", [240, 255, 240]),
|
||||
("hotpink", [255, 105, 180]),
|
||||
("indianred", [205, 92, 92]),
|
||||
("indigo", [75, 0, 130]),
|
||||
("ivory", [255, 255, 240]),
|
||||
("khaki", [240, 230, 140]),
|
||||
("lavender", [230, 230, 250]),
|
||||
("lavenderblush", [255, 240, 245]),
|
||||
("lawngreen", [124, 252, 0]),
|
||||
("lemonchiffon", [255, 250, 205]),
|
||||
("lightblue", [173, 216, 230]),
|
||||
("lightcoral", [240, 128, 128]),
|
||||
("lightcyan", [224, 255, 255]),
|
||||
("lightgoldenrodyellow", [250, 250, 210]),
|
||||
("lightgray", [211, 211, 211]),
|
||||
("lightgreen", [144, 238, 144]),
|
||||
("lightgrey", [211, 211, 211]),
|
||||
("lightpink", [255, 182, 193]),
|
||||
("lightsalmon", [255, 160, 122]),
|
||||
("lightseagreen", [32, 178, 170]),
|
||||
("lightskyblue", [135, 206, 250]),
|
||||
("lightslategray", [119, 136, 153]),
|
||||
("lightslategrey", [119, 136, 153]),
|
||||
("lightsteelblue", [176, 196, 222]),
|
||||
("lightyellow", [255, 255, 224]),
|
||||
("lime", [0, 255, 0]),
|
||||
("limegreen", [50, 205, 50]),
|
||||
("linen", [250, 240, 230]),
|
||||
("magenta", [255, 0, 255]),
|
||||
("maroon", [128, 0, 0]),
|
||||
("mediumaquamarine", [102, 205, 170]),
|
||||
("mediumblue", [0, 0, 205]),
|
||||
("mediumorchid", [186, 85, 211]),
|
||||
("mediumpurple", [147, 112, 219]),
|
||||
("mediumseagreen", [60, 179, 113]),
|
||||
("mediumslateblue", [123, 104, 238]),
|
||||
("mediumspringgreen", [0, 250, 154]),
|
||||
("mediumturquoise", [72, 209, 204]),
|
||||
("mediumvioletred", [199, 21, 133]),
|
||||
("midnightblue", [25, 25, 112]),
|
||||
("mintcream", [245, 255, 250]),
|
||||
("mistyrose", [255, 228, 225]),
|
||||
("moccasin", [255, 228, 181]),
|
||||
("navajowhite", [255, 222, 173]),
|
||||
("navy", [0, 0, 128]),
|
||||
("oldlace", [253, 245, 230]),
|
||||
("olive", [128, 128, 0]),
|
||||
("olivedrab", [107, 142, 35]),
|
||||
("orange", [255, 165, 0]),
|
||||
("orangered", [255, 69, 0]),
|
||||
("orchid", [218, 112, 214]),
|
||||
("palegoldenrod", [238, 232, 170]),
|
||||
("palegreen", [152, 251, 152]),
|
||||
("paleturquoise", [175, 238, 238]),
|
||||
("palevioletred", [219, 112, 147]),
|
||||
("papayawhip", [255, 239, 213]),
|
||||
("peachpuff", [255, 218, 185]),
|
||||
("peru", [205, 133, 63]),
|
||||
("pink", [255, 192, 203]),
|
||||
("plum", [221, 160, 221]),
|
||||
("powderblue", [176, 224, 230]),
|
||||
("purple", [128, 0, 128]),
|
||||
("rebeccapurple", [102, 51, 153]),
|
||||
("red", [255, 0, 0]),
|
||||
("rosybrown", [188, 143, 143]),
|
||||
("royalblue", [65, 105, 225]),
|
||||
("saddlebrown", [139, 69, 19]),
|
||||
("salmon", [250, 128, 114]),
|
||||
("sandybrown", [244, 164, 96]),
|
||||
("seagreen", [46, 139, 87]),
|
||||
("seashell", [255, 245, 238]),
|
||||
("sienna", [160, 82, 45]),
|
||||
("silver", [192, 192, 192]),
|
||||
("skyblue", [135, 206, 235]),
|
||||
("slateblue", [106, 90, 205]),
|
||||
("slategray", [112, 128, 144]),
|
||||
("slategrey", [112, 128, 144]),
|
||||
("snow", [255, 250, 250]),
|
||||
("springgreen", [0, 255, 127]),
|
||||
("steelblue", [70, 130, 180]),
|
||||
("tan", [210, 180, 140]),
|
||||
("teal", [0, 128, 128]),
|
||||
("thistle", [216, 191, 216]),
|
||||
("tomato", [255, 99, 71]),
|
||||
("turquoise", [64, 224, 208]),
|
||||
("violet", [238, 130, 238]),
|
||||
("wheat", [245, 222, 179]),
|
||||
("white", [255, 255, 255]),
|
||||
("whitesmoke", [245, 245, 245]),
|
||||
("yellow", [255, 255, 0]),
|
||||
("yellowgreen", [154, 205, 50]),
|
||||
];
|
||||
|
||||
/// Longest name in [`NAMED`]; anything longer can't match.
|
||||
const LONGEST: usize = 20;
|
||||
|
||||
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
|
||||
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
|
||||
pub fn lookup(s: &str) -> Option<[u8; 3]> {
|
||||
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
|
||||
if key.is_empty() || key.len() > LONGEST {
|
||||
return None;
|
||||
}
|
||||
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_is_sorted_and_unique() {
|
||||
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
|
||||
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_name_is_found() {
|
||||
for (name, rgb) in NAMED {
|
||||
assert_eq!(lookup(name), Some(rgb), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_normalises() {
|
||||
assert_eq!(lookup("Red"), Some([255, 0, 0]));
|
||||
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
|
||||
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
|
||||
assert_eq!(lookup(""), None);
|
||||
assert_eq!(lookup("transparent"), None);
|
||||
assert_eq!(lookup("reddish"), None);
|
||||
assert_eq!(lookup("rédé"), None);
|
||||
assert_eq!(lookup(&"a".repeat(10_000)), None);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,23 @@ fn css_parsing() {
|
||||
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
|
||||
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
|
||||
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
|
||||
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fields_take_names() {
|
||||
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
|
||||
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
|
||||
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
|
||||
// Hex wins: every digit string still reads as hex.
|
||||
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
|
||||
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
|
||||
// Names are opaque; `transparent` is not a field colour.
|
||||
assert!(Color::from_hex_or_name("transparent").is_none());
|
||||
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
|
||||
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -781,6 +781,23 @@ impl Document {
|
||||
self.paste_nodes(&src_nodes, &map, id, parent, index)
|
||||
}
|
||||
|
||||
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
|
||||
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
|
||||
/// from one copied frame to another) point at the copies.
|
||||
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut trees = Vec::with_capacity(ids.len());
|
||||
for &id in ids {
|
||||
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
|
||||
for n in &src_nodes {
|
||||
let nid = self.alloc_id();
|
||||
map.insert(n.id, nid);
|
||||
}
|
||||
trees.push((id, src_nodes));
|
||||
}
|
||||
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
|
||||
}
|
||||
|
||||
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
||||
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
|
||||
/// subtree are remapped too.
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Expr {
|
||||
return "…".into();
|
||||
}
|
||||
match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::String(s)) => quote(s),
|
||||
// Exact numbers and `#` colours, so the text reads back as the same expression.
|
||||
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
|
||||
@@ -178,6 +178,18 @@ impl Expr {
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable as written in an expression: its bare name when that reads back as this variable,
|
||||
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
|
||||
/// `"q"` don't read back as literals or errors.
|
||||
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
|
||||
let this = Expr::var(v.id.clone());
|
||||
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
|
||||
return v.name.clone();
|
||||
}
|
||||
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
|
||||
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
|
||||
}
|
||||
|
||||
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
|
||||
/// spaces at its ends, quotes or brackets.
|
||||
fn name_arg(name: &str) -> String {
|
||||
|
||||
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_look_like_literals_read_back_as_variables() {
|
||||
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
|
||||
let mut v = vars();
|
||||
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
|
||||
var.name = name.into();
|
||||
}
|
||||
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
|
||||
let shown = e.display(&v);
|
||||
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
|
||||
}
|
||||
}
|
||||
// Plain names stay bare, and literals still read as literals.
|
||||
let v = vars();
|
||||
assert_eq!(Expr::var("n").display(&v), "n-name");
|
||||
assert_eq!(parse("1"), n(1.0));
|
||||
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
|
||||
}
|
||||
@@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() {
|
||||
assert_eq!(t.characters, "");
|
||||
}
|
||||
|
||||
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
|
||||
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
|
||||
/// text unwritable.
|
||||
#[test]
|
||||
fn text_runs_past_the_text_never_overflow() {
|
||||
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
|
||||
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
|
||||
let mut t = TextData::new("hi");
|
||||
t.runs = vec![StyleRun { start: 0, end, style: style() }];
|
||||
// Typing at the caret, and replacing a selection: both shift `end`.
|
||||
t.replace_range(0, 0, "x");
|
||||
assert_eq!(t.characters, "xhi", "end {end}");
|
||||
t.replace_range(0, 3, "yo");
|
||||
assert_eq!(t.characters, "yo", "end {end}");
|
||||
// The text is still writable and the runs are back inside it.
|
||||
t.replace_range(0, 0, "!");
|
||||
assert_eq!(t.characters, "!yo", "end {end}");
|
||||
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_case() {
|
||||
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
|
||||
@@ -381,3 +402,65 @@ fn custom_easings() {
|
||||
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
|
||||
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_updates_merge_equal_runs() {
|
||||
let def = TextStyle { font_size: 14.0, ..Default::default() };
|
||||
let mut t = TextData::new("abcd");
|
||||
t.link_text_style(0, 2, &def, "S");
|
||||
t.link_text_style(2, 4, &def, "S");
|
||||
t.restyle(2, 4, |s| s.font_size = 24.0);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
|
||||
let spans = t.spans();
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!((spans[0].0, spans[0].1), (0, 4));
|
||||
assert_eq!(spans[0].2.font_size, 24.0);
|
||||
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
|
||||
|
||||
// Remapping two links to one id merges the runs.
|
||||
let mut t = TextData::new("ab");
|
||||
t.link_text_style(0, 1, &def, "A");
|
||||
t.link_text_style(1, 2, &def, "B");
|
||||
t.map_text_styles(|_| Some("C".into()));
|
||||
assert_eq!(t.spans().len(), 1);
|
||||
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
|
||||
|
||||
// Different values stay separate.
|
||||
let mut t = TextData::new("ab");
|
||||
t.link_text_style(0, 1, &def, "A");
|
||||
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
|
||||
t.map_text_styles(|_| Some("C".into()));
|
||||
let spans = t.spans();
|
||||
assert_eq!(spans.len(), 2);
|
||||
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
|
||||
|
||||
// Unlinking a run equal to the base drops it.
|
||||
let mut t = TextData::new("ab");
|
||||
let base = t.style.clone();
|
||||
t.link_text_style(0, 1, &base, "S");
|
||||
t.map_text_styles(|_| None);
|
||||
assert!(t.runs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_empty_range_links_and_unlinks_all() {
|
||||
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
|
||||
let mut t = TextData::new("abcd");
|
||||
t.link_text_style(2, 2, &def, "S");
|
||||
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
|
||||
t.unlink_text_style(2, 2);
|
||||
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
|
||||
// Partial ranges stay partial.
|
||||
t.link_text_style(0, 4, &def, "S");
|
||||
t.unlink_text_style(1, 3);
|
||||
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
|
||||
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
|
||||
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
|
||||
// Empty text changes the base style.
|
||||
let mut e = TextData::new("");
|
||||
e.link_text_style(0, 0, &def, "S");
|
||||
assert_eq!(e.style.style_id.as_deref(), Some("S"));
|
||||
e.unlink_text_style(0, 0);
|
||||
assert!(e.style.style_id.is_none());
|
||||
}
|
||||
+14
-2
@@ -562,7 +562,7 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style.
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
|
||||
pub fn normalize(&mut self) {
|
||||
let n = self.char_count();
|
||||
let base = self.style.clone();
|
||||
@@ -571,6 +571,14 @@ impl TextData {
|
||||
r.end = r.end.min(n);
|
||||
}
|
||||
self.runs.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
|
||||
for r in std::mem::take(&mut self.runs) {
|
||||
match merged.last_mut() {
|
||||
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
|
||||
_ => merged.push(r),
|
||||
}
|
||||
}
|
||||
self.runs = merged;
|
||||
}
|
||||
|
||||
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
|
||||
@@ -602,7 +610,9 @@ impl TextData {
|
||||
if p <= start {
|
||||
p
|
||||
} else if p >= end {
|
||||
p - removed + inserted
|
||||
// Runs of a file that reach past the text (nothing clamps them on load)
|
||||
// would otherwise overflow here; `normalize` below drops what is left.
|
||||
p.saturating_sub(removed).saturating_add(inserted)
|
||||
} else {
|
||||
start + inserted
|
||||
}
|
||||
@@ -675,11 +685,13 @@ impl TextData {
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
/// style `id`, defined as `def`.
|
||||
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
|
||||
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
|
||||
self.restyle(start, end, |s| s.take_text_style(def, id));
|
||||
}
|
||||
|
||||
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
|
||||
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
|
||||
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
|
||||
self.restyle(start, end, |s| s.style_id = None);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ impl VectorNetwork {
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
|
||||
(Some(i), None | Some(false)) => {
|
||||
self.regions.remove(i);
|
||||
(Some(_), None | Some(false)) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
Some(false)
|
||||
}
|
||||
(Some(_), Some(true)) => Some(true),
|
||||
@@ -525,4 +525,21 @@ mod tests {
|
||||
let _ = j.paint(Point::new(f64::NAN, 5.0));
|
||||
assert!(interior_point(&BezPath::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.regions.push(n.regions[0].clone());
|
||||
assert_eq!(n.paint(c), Some(false));
|
||||
assert!(!n.fill_contains(c));
|
||||
assert_eq!(n.region_at(c), None);
|
||||
assert_eq!(n.paint(c), Some(true));
|
||||
assert!(n.fill_contains(c));
|
||||
|
||||
let mut e = square(0.0, 0.0, 100.0);
|
||||
e.regions.push(e.regions[0].clone());
|
||||
assert_eq!(e.paint_to(c, Some(false)), Some(false));
|
||||
assert!(!e.fill_contains(c));
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,9 @@ impl VectorNetwork {
|
||||
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
|
||||
/// as stored. Missing segments are skipped.
|
||||
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
|
||||
let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() };
|
||||
let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) {
|
||||
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
|
||||
let Some(first) = existing.next() else { return Vec::new() };
|
||||
let forward_first = match existing.next() {
|
||||
Some(second) => first.b == second.a || first.b == second.b,
|
||||
None => true,
|
||||
};
|
||||
@@ -102,10 +103,18 @@ impl VectorNetwork {
|
||||
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
|
||||
}
|
||||
|
||||
/// Every point on region `r`'s loops (holes' too), each once, sorted.
|
||||
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
|
||||
/// don't exist are left out).
|
||||
pub fn region_points(&self, r: usize) -> Vec<usize> {
|
||||
let Some(region) = self.regions.get(r) else { return Vec::new() };
|
||||
let mut out: Vec<usize> = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect();
|
||||
let mut out: Vec<usize> = region
|
||||
.loops
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|i| self.segments.get(*i))
|
||||
.flat_map(|s| [s.a, s.b])
|
||||
.filter(|v| *v < self.vertices.len())
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
|
||||
@@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() {
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
|
||||
use figcraft_geom::Shape;
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
|
||||
let control = n.region_path(0).unwrap();
|
||||
let control_walk = n.loop_walk(&[0, 1, 2]);
|
||||
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
|
||||
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
|
||||
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
|
||||
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
|
||||
let path = n.region_path(0).unwrap();
|
||||
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
|
||||
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
|
||||
}
|
||||
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
|
||||
assert!(n.region_path(0).is_none());
|
||||
assert!(!n.region_contains(0, p(100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_regions_points_are_listed_once() {
|
||||
let mut n = nested();
|
||||
@@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_points_leave_out_segment_ends_that_do_not_exist() {
|
||||
let mut n = VectorNetwork::default();
|
||||
let ring = square(&mut n, 0.0, 0.0, 100.0);
|
||||
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
n.segments[3] = Segment::line(3, 9);
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
assert!(n.region_path(0).is_some());
|
||||
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
|
||||
assert!(n.region_points(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
|
||||
let mut n = nested();
|
||||
|
||||
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
|
||||
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_item_wrapped_lines_move_and_drop_by_line() {
|
||||
let mut s = Session::new();
|
||||
// Full-width items: one per row.
|
||||
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
|
||||
set(&mut s, row, json!({"wrap": true}));
|
||||
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
|
||||
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
|
||||
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
|
||||
arrow(&mut s, &[k[1]], 0, -1);
|
||||
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
|
||||
|
||||
// Full-height items in a vertical wrap: one per column.
|
||||
let col = al(&mut s, None, "vertical", 200.0, 160.0);
|
||||
set(&mut s, col, json!({"wrap": true}));
|
||||
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
|
||||
arrow(&mut s, &[c[2]], -1, 0);
|
||||
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rows_move_up_and_down_a_row() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(td) = &mut kind {
|
||||
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
|
||||
td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default());
|
||||
if w > 0.0 {
|
||||
td.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
|
||||
@@ -1003,7 +1003,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if find.is_empty() {
|
||||
return Err(bad("edit.findReplace", "`find` is empty"));
|
||||
}
|
||||
let replace = string(p, "replace");
|
||||
// Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF).
|
||||
let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r));
|
||||
let names = boolean(p, "layerNames").unwrap_or(false);
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
@@ -824,12 +824,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
|
||||
}
|
||||
|
||||
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
|
||||
/// area), or their boxes with "include bounding box" and for slices.
|
||||
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
|
||||
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
|
||||
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
|
||||
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
|
||||
ids.iter()
|
||||
.filter_map(|i| {
|
||||
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
|
||||
let node = doc.node(*i);
|
||||
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice {
|
||||
doc.world_bounds(*i)
|
||||
} else if any_visible && node.is_some_and(|n| !n.visible) {
|
||||
None
|
||||
} else {
|
||||
figcraft_render::export_bounds(doc, *i)
|
||||
}
|
||||
})
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Pages.
|
||||
|
||||
use figcraft_doc::proto::Flow;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
|
||||
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
|
||||
let Some(target) = id(p, "id") else { return s.page() };
|
||||
if !s.doc()?.pages.contains(&target) {
|
||||
return Err(bad(cmd, format!("{target} is not a page")));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
|
||||
let name = req_string("page.rename", p, "name")?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.rename", p)?;
|
||||
s.doc_mut()?.get_mut(id)?.name = name;
|
||||
settle(s, "page.rename")?;
|
||||
ok()
|
||||
}),
|
||||
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.delete", p)?;
|
||||
s.doc_mut()?.remove(id)?;
|
||||
settle(s, "page.delete")?;
|
||||
ok()
|
||||
@@ -87,13 +97,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let doc = s.doc_mut()?;
|
||||
let name = format!("Copy of {}", doc.get(src)?.name);
|
||||
let np = doc.add_page(&name);
|
||||
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
|
||||
// One copy with one id map, so navigation between the copied frames points at the copies;
|
||||
// then each layer and its copy (also those in sections), so flows can follow their start frames.
|
||||
let children = doc.get(src)?.children.clone();
|
||||
let copied = doc.duplicate_all_into(&children, np)?;
|
||||
let mut copies = Vec::new();
|
||||
for (c, copy) in children.iter().zip(&copied) {
|
||||
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
|
||||
}
|
||||
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
|
||||
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
|
||||
{
|
||||
*b = background;
|
||||
}
|
||||
for c in doc.get(src)?.children.clone() {
|
||||
doc.duplicate_into(c, np, None)?;
|
||||
*f = flows
|
||||
.into_iter()
|
||||
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
|
||||
.collect();
|
||||
*pr = prototype;
|
||||
*g = guides;
|
||||
}
|
||||
// Keep the new page right after the source.
|
||||
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
|
||||
@@ -117,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
|
||||
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.setBackground", p)?;
|
||||
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
|
||||
*background = c;
|
||||
}
|
||||
@@ -163,8 +185,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
guides.remove(k);
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.guide == Some((owner, k)) {
|
||||
ds.guide = None;
|
||||
match ds.guide {
|
||||
Some((o, i)) if o == owner && i == k => ds.guide = None,
|
||||
// A later guide of the same owner moves up one place; keep the selection on it.
|
||||
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
|
||||
_ => {}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\
|
||||
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
|
||||
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same).
|
||||
pub(crate) fn normalize_line_endings(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
}
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
pub fn snake(v: &Value) -> Value {
|
||||
match v {
|
||||
@@ -599,7 +604,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let n = doc.get_mut(id)?;
|
||||
let t = text_mut(cmd, n)?;
|
||||
let len = t.char_count();
|
||||
t.replace_range(0, len, &s);
|
||||
t.replace_range(0, len, &normalize_line_endings(&s));
|
||||
n.overrides.insert("characters".into());
|
||||
}
|
||||
"fontFamily"
|
||||
|
||||
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
|
||||
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let scopes = match get(p, "scopes") {
|
||||
let scopes = match p.get("scopes") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) => {
|
||||
|
||||
@@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let pages = &ds.doc.pages;
|
||||
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
|
||||
let n = pages.len() as i64;
|
||||
let next = (i + dir).clamp(0, n - 1) as usize;
|
||||
let Some(pg) = pages.get(next).copied() else { return ok() };
|
||||
// Dividers never open: step over them, and stay put when only dividers lie ahead.
|
||||
let mut next = i + dir;
|
||||
let pg = loop {
|
||||
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
|
||||
match page {
|
||||
Some(p) if !ds.doc.is_divider(p) => break p,
|
||||
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
|
||||
_ => break ds.page,
|
||||
}
|
||||
};
|
||||
let ds = s.ds_mut()?;
|
||||
ds.page = pg;
|
||||
ds.selection.clear();
|
||||
|
||||
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
|
||||
/// in "px" are left alone.
|
||||
/// in "px", markup lines (layer text), string literals and comments are left alone.
|
||||
pub fn px_to_rem(code: &str, root: f64) -> String {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut in_comment = false;
|
||||
for line in code.split_inclusive('\n') {
|
||||
if !in_comment && line.trim_start().starts_with('<') {
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
|
||||
let mut plain = 0;
|
||||
let mut chars = line.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if in_comment {
|
||||
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
|
||||
chars.next();
|
||||
out.push_str(line.get(plain..i + 2).unwrap_or(""));
|
||||
plain = i + 2;
|
||||
in_comment = false;
|
||||
}
|
||||
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
|
||||
chars.next();
|
||||
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
|
||||
plain = i;
|
||||
in_comment = true;
|
||||
} else if c == '"' {
|
||||
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
|
||||
let mut end = line.len();
|
||||
while let Some((j, s)) = chars.next() {
|
||||
if s == '\\' {
|
||||
chars.next();
|
||||
} else if s == '"' {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str(line.get(i..end).unwrap_or(""));
|
||||
plain = end;
|
||||
}
|
||||
}
|
||||
let rest = line.get(plain..).unwrap_or("");
|
||||
if in_comment {
|
||||
out.push_str(rest);
|
||||
} else {
|
||||
out.push_str(&lengths_to_rem(rest, root));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
|
||||
fn lengths_to_rem(code: &str, root: f64) -> String {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut rest = code;
|
||||
|
||||
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `data-*` attribute names for variant parameters.
|
||||
fn data_attr(p: &Param) -> String {
|
||||
/// `data-*` attribute names for variant parameters, unique among them (in order).
|
||||
fn data_attrs(variants: &[&Param]) -> Vec<String> {
|
||||
let mut used = Vec::new();
|
||||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
|
||||
}
|
||||
|
||||
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
|
||||
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let root_class = class_name(&root_name, &mut Vec::new());
|
||||
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let attr_names = data_attrs(&variants);
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, lang);
|
||||
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.root_attrs = variants
|
||||
.iter()
|
||||
.map(|p| match lang {
|
||||
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
|
||||
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
|
||||
.zip(&attr_names)
|
||||
.map(|(p, attr)| match lang {
|
||||
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
|
||||
_ => format!(" {attr}={{{}}}", p.ident),
|
||||
})
|
||||
.collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
let pairs = variant_props(&vn.name);
|
||||
let attrs: String = variants
|
||||
.iter()
|
||||
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
|
||||
.zip(&attr_names)
|
||||
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
|
||||
.collect();
|
||||
for (path, _, decls) in pass(v) {
|
||||
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
|
||||
|
||||
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
|
||||
let react = code(&mut s, c, "react");
|
||||
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_variant_properties_get_distinct_data_attributes() {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
|
||||
let name = format!("Size={first}, size={second}");
|
||||
let f = idv(&ex(
|
||||
&mut s,
|
||||
"frame.create",
|
||||
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
|
||||
));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
|
||||
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
|
||||
for lang in ["react", "leptos"] {
|
||||
let v = component_code(&mut s, set, lang);
|
||||
let def = v["definition"].as_str().unwrap_or_default();
|
||||
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
|
||||
let attrs: Vec<&str> =
|
||||
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
|
||||
assert_eq!(attrs.len(), 2, "{lang}: {root}");
|
||||
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
|
||||
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
|
||||
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
|
||||
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
|
||||
}
|
||||
}
|
||||
@@ -472,11 +472,11 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
|
||||
}
|
||||
}
|
||||
"characters" => {
|
||||
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str())
|
||||
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
|
||||
&& t.characters != sv
|
||||
{
|
||||
let len = t.char_count();
|
||||
t.replace_range(0, len, sv);
|
||||
t.replace_range(0, len, &sv);
|
||||
}
|
||||
}
|
||||
"mainComponent" => {
|
||||
|
||||
@@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
// A real click: the mouse moves a pixel or two between press and release.
|
||||
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
|
||||
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
|
||||
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
|
||||
assert!(s.tool.overlay.pen_handles.is_empty());
|
||||
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
|
||||
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
|
||||
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
|
||||
assert_eq!(out, Some((4.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_shows_what_a_click_would_do() {
|
||||
// The Pen tool drawing a new path: three points, then over each of them.
|
||||
|
||||
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
|
||||
click(&mut s, 350.0, 100.0);
|
||||
assert_eq!(s.selection(), vec![b], "everything is hit again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
|
||||
let mut s = Session::new();
|
||||
let screen = frame(&mut s, 0.0, "Screen");
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
|
||||
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
|
||||
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
|
||||
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
|
||||
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
|
||||
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
|
||||
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
|
||||
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
|
||||
ex(&mut s, "dev.focus", json!({"id": screen}));
|
||||
for direction in ["next", "previous"] {
|
||||
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
|
||||
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
|
||||
}
|
||||
// A frame directly inside a section still counts.
|
||||
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
|
||||
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
|
||||
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
|
||||
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
|
||||
}
|
||||
|
||||
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
|
||||
/// in page and layer order (bounded).
|
||||
/// in page and layer order (bounded). Only layers that can carry a status are listed.
|
||||
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
|
||||
let mut out = Vec::new();
|
||||
let mut visited = 0usize;
|
||||
@@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
|
||||
return out;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
|
||||
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
|
||||
}
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
|
||||
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
|
||||
|
||||
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
|
||||
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
|
||||
/// wrap in flow order, so a new row starts where a child sits before the one before.
|
||||
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
|
||||
/// does not follow the one before along the flow but sits past it across (one child per line).
|
||||
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
|
||||
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
|
||||
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
|
||||
match rows.last_mut() {
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
|
||||
row.push((i, b))
|
||||
}
|
||||
_ => rows.push(vec![(i, b)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
|
||||
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
|
||||
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
|
||||
}
|
||||
|
||||
fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String {
|
||||
let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap();
|
||||
out["code"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_preserves_text_in_web_markup() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap());
|
||||
s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap();
|
||||
let mut changed = Vec::new();
|
||||
for language in ["html", "react", "leptos"] {
|
||||
let px = web_code(&mut s, frame, language, "px");
|
||||
let rem = web_code(&mut s, frame, language, "rem");
|
||||
assert!(px.contains("Tile 24px"), "{language}: px control: {px}");
|
||||
assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}");
|
||||
assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}");
|
||||
if !rem.contains("Tile 24px") {
|
||||
changed.push(language);
|
||||
}
|
||||
}
|
||||
assert!(changed.is_empty(), "text rewritten in {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_preserves_text_property_default() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap());
|
||||
let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap());
|
||||
let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap();
|
||||
for unit in ["px", "rem"] {
|
||||
let code = web_code(&mut s, component, "react", unit);
|
||||
assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}");
|
||||
if unit == "rem" {
|
||||
assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,8 +1236,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
|
||||
pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
// Only what was touched since the last normalisation (edited, added or removed).
|
||||
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
|
||||
// A variable edit changes no layer directly, but bound layers must follow it.
|
||||
if touched.is_empty() && doc.variables == before.variables {
|
||||
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
|
||||
if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
|
||||
return;
|
||||
}
|
||||
let m = measure::TextMeasure;
|
||||
@@ -1558,8 +1558,12 @@ mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_range_styles_tests;
|
||||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
#[cfg(test)]
|
||||
mod line_endings_tests;
|
||||
#[cfg(test)]
|
||||
mod live_access_tests;
|
||||
#[cfg(test)]
|
||||
mod live_docs_tests;
|
||||
@@ -1586,6 +1590,10 @@ mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod page_copy_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod page_target_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
|
||||
@@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text).
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on
|
||||
/// character ranges).
|
||||
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
|
||||
let chars =
|
||||
n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref());
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars)
|
||||
}
|
||||
|
||||
/// Style ids the layers under `roots` link to.
|
||||
@@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
}
|
||||
|
||||
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
|
||||
/// the library's are refreshed). Returns library style id → local id.
|
||||
/// the library's are refreshed). The variables a written copy binds are imported too and its
|
||||
/// bindings point at their copies (never at this file's variables of the same ids). Returns library
|
||||
/// style id → local id.
|
||||
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
|
||||
let lid = lib.manifest.id.clone();
|
||||
let mut out = BTreeMap::new();
|
||||
let mut hashes = BTreeSet::new();
|
||||
// Local ids of the copies written now, with the library style they copy.
|
||||
let mut written: Vec<(String, &Style)> = Vec::new();
|
||||
for key in keys {
|
||||
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
|
||||
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
|
||||
@@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
{
|
||||
*slot = Style { id: r.local.clone(), ..style.clone() };
|
||||
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
|
||||
written.push((r.local.clone(), style));
|
||||
}
|
||||
r.local
|
||||
}
|
||||
@@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
|
||||
doc.styles.push(Style { id: local.clone(), ..style.clone() });
|
||||
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
|
||||
written.push((local.clone(), style));
|
||||
local
|
||||
}
|
||||
};
|
||||
@@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
}
|
||||
out.insert(key.clone(), local);
|
||||
}
|
||||
let bound: BTreeSet<VariableId> = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect();
|
||||
if !bound.is_empty() {
|
||||
let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update);
|
||||
for (local, _) in &written {
|
||||
if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) {
|
||||
// Bindings to variables the library lacks are dropped (the published value stays).
|
||||
slot.bound =
|
||||
std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_images(doc, &lib.doc, &hashes);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Text styles linked on character ranges travel with components (publish and place).
|
||||
|
||||
use crate::Session;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn node(value: Value) -> NodeId {
|
||||
NodeId(value["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style).
|
||||
fn fixture() -> (Session, NodeId, String, String) {
|
||||
let mut s = Session::new();
|
||||
let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40})));
|
||||
let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"})));
|
||||
let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned();
|
||||
run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2}));
|
||||
let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"})));
|
||||
let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned();
|
||||
run(&mut s, "style.apply", json!({"id":whole_text, "style":whole}));
|
||||
let component = node(run(&mut s, "component.create", json!({"ids":[frame]})));
|
||||
let source = s.doc().unwrap().node(text).unwrap();
|
||||
let chars = source.kind.text().unwrap();
|
||||
assert!(source.styles.text.is_none());
|
||||
assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str()));
|
||||
assert_eq!(chars.style_at(2).style_id, None);
|
||||
(s, component, range, whole)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_publish_keeps_range_text_style() {
|
||||
let (mut s, component, range, whole) = fixture();
|
||||
run(&mut s, "library.publish", json!({"include":[component]}));
|
||||
let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc;
|
||||
assert!(snapshot.style(&whole).is_some());
|
||||
assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_place_imports_range_text_style() {
|
||||
let (mut source, component, range, whole) = fixture();
|
||||
let publication = run(&mut source, "library.publish", json!({}));
|
||||
let library = source.doc().unwrap().published.as_ref().unwrap().clone();
|
||||
assert!(library.doc.style(&range).is_some());
|
||||
let mut target = Session::new();
|
||||
let path = "figcraft://library.test/range-styles";
|
||||
target.libraries.insert(path, Ok(library));
|
||||
run(&mut target, "library.add", json!({"path":path}));
|
||||
let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component})));
|
||||
let doc = target.doc().unwrap();
|
||||
let children = &doc.node(instance).unwrap().children;
|
||||
let ranged = doc.node(children[0]).unwrap().kind.text().unwrap();
|
||||
let full = doc.node(children[1]).unwrap().kind.text().unwrap();
|
||||
let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap();
|
||||
assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str()));
|
||||
assert_eq!(ranged.style_at(0).font_size, 32.0);
|
||||
assert!(ranged.style_at(2).style_id.is_none());
|
||||
let record = doc.libraries.styles.iter().find(|r| r.key == range);
|
||||
assert!(record.is_some(), "character-range style missing after placement");
|
||||
assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str()));
|
||||
assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some());
|
||||
}
|
||||
@@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() {
|
||||
}
|
||||
assert!(s.doc().unwrap().libraries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_style_bindings_ignore_unrelated_local_variables() {
|
||||
let d = dir("style-bound");
|
||||
let path = path_str(&d.join("lib.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"}));
|
||||
let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone();
|
||||
let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone();
|
||||
ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var}));
|
||||
let lid = publish_to(&mut lib, &path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Local"}));
|
||||
let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone();
|
||||
assert_eq!(local, var, "the ids must collide for this test");
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));
|
||||
ex(&mut s, "library.add", json!({"path": path}));
|
||||
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));
|
||||
assert_eq!(fill(&s, r), "0000FF");
|
||||
healthy(&s);
|
||||
|
||||
// An updated style follows the library's variable, not the local one.
|
||||
ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, r), "00FFFF");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Text set by a command stores every line ending as LF, like editor insertion does, so layout
|
||||
//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{card, chars, copy_of, ex, idv};
|
||||
use crate::params::id_of;
|
||||
|
||||
const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"];
|
||||
|
||||
#[test]
|
||||
fn text_set_normalizes_line_endings() {
|
||||
let mut s = Session::new();
|
||||
let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap();
|
||||
let id = id_of(&created["id"]).unwrap();
|
||||
for input in INPUTS {
|
||||
s.execute("text.set", &json!({"id": id, "characters": input})).unwrap();
|
||||
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!(t.characters, "a\nb", "text.set {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_create_normalizes_line_endings() {
|
||||
let mut s = Session::new();
|
||||
for input in INPUTS {
|
||||
let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap();
|
||||
let id = id_of(&created["id"]).unwrap();
|
||||
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!(t.characters, "a\nb", "text.create {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_normalizes_line_endings() {
|
||||
for input in INPUTS {
|
||||
let mut s = Session::new();
|
||||
let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0})));
|
||||
ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input}));
|
||||
assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_property_values_normalize_line_endings() {
|
||||
let mut s = Session::new();
|
||||
let (c, _bg, title, _dot) = card(&mut s);
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let copy = copy_of(&s, i, title);
|
||||
for input in INPUTS {
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}}));
|
||||
assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Duplicating a page copies its prototype connections: navigation between two frames that are both
|
||||
//! on the page leads between the copies, while a destination outside the copied contents is kept.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn frame(s: &mut Session, x: f64) -> NodeId {
|
||||
id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn dest(s: &Session, id: NodeId) -> NodeId {
|
||||
s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_copy_navigation_uses_the_copied_destination() {
|
||||
let mut s = Session::new();
|
||||
let first = frame(&mut s, 0.0);
|
||||
let second = frame(&mut s, 200.0);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let children = s.doc().unwrap().node(copy).unwrap().children.clone();
|
||||
assert_eq!(children.len(), 2);
|
||||
assert_eq!(dest(&s, children[0]), children[1]);
|
||||
let visited = Default::default();
|
||||
assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1]));
|
||||
assert_eq!(dest(&s, first), second, "the original keeps its destination");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_copy_keeps_an_outside_destination() {
|
||||
let mut s = Session::new();
|
||||
let first = frame(&mut s, 0.0);
|
||||
let second = frame(&mut s, 200.0);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [first]}));
|
||||
ex(&mut s, "edit.duplicate", json!({}));
|
||||
let copy = s.selection()[0];
|
||||
assert_ne!(copy, first);
|
||||
assert_eq!(dest(&s, copy), second);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about
|
||||
//! the layer changes, as `page.select` already does.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn name(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().get(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_commands_reject_a_rectangle() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
let before = name(&s, r);
|
||||
assert!(s.execute("page.select", &json!({"id": r})).is_err());
|
||||
assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer");
|
||||
assert_eq!(name(&s, r), before, "the layer keeps its name");
|
||||
assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer");
|
||||
assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer");
|
||||
assert!(s.doc().unwrap().contains(r), "the layer is still there");
|
||||
assert!(s.doc().unwrap().contains(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_delete_keeps_a_frame_and_its_child() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})));
|
||||
assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f));
|
||||
assert!(s.execute("page.delete", &json!({"id": f})).is_err());
|
||||
assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain");
|
||||
assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err());
|
||||
assert_ne!(name(&s, f), "Not a page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_commands_still_work_on_pages() {
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let second = id_of(&ex(&mut s, "page.add", json!({})));
|
||||
ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"}));
|
||||
assert_eq!(name(&s, second), "Flows");
|
||||
ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"}));
|
||||
match s.doc().unwrap().get(second).unwrap().kind {
|
||||
NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"),
|
||||
_ => panic!("not a page"),
|
||||
}
|
||||
ex(&mut s, "page.delete", json!({"id": second}));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
|
||||
}
|
||||
@@ -425,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
|
||||
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_navigation_skips_dividers() {
|
||||
// A trailing divider: Next page stays on the ordinary page.
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
|
||||
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
|
||||
let r = ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider");
|
||||
assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page");
|
||||
// A middle divider: Previous page crosses it, Next page crosses it back.
|
||||
let later = id(&ex(&mut s, "page.add", json!({"name": "Later"})));
|
||||
assert_eq!(s.page().unwrap(), later);
|
||||
let r = ex(&mut s, "view.prevPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider");
|
||||
assert_eq!(id_of(&r["page"]), Some(first));
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page crosses a divider");
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
|
||||
}
|
||||
@@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let whole_shape =
|
||||
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
|
||||
if merge && whole_shape {
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let created = if merge {
|
||||
|
||||
@@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
made += 4;
|
||||
}
|
||||
// A card group (5 layers).
|
||||
if budget(made) >= 5 {
|
||||
// The card and the instances leave one layer for the grid, which starts with a rectangle.
|
||||
if budget(made) > 5 {
|
||||
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
|
||||
// Children are in the group's space; normalising fits the group around them.
|
||||
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
|
||||
@@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
}
|
||||
// Three button instances (3 layers each).
|
||||
for k in 0..3 {
|
||||
if budget(made) < 3 {
|
||||
if budget(made) < 3 + 1 {
|
||||
break;
|
||||
}
|
||||
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
|
||||
@@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
for k in 0..rest {
|
||||
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
|
||||
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
|
||||
let id = match k % 5 {
|
||||
// The grid starts with a rectangle so even the smallest workload has one.
|
||||
let kind = (k + 2) % 5;
|
||||
let id = match kind {
|
||||
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
|
||||
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
|
||||
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
|
||||
};
|
||||
if k % 5 != 0 {
|
||||
if kind != 0 {
|
||||
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.corners.radii = [3.0; 4];
|
||||
@@ -124,4 +127,16 @@ mod tests {
|
||||
assert!(doc.page_content_bounds(page).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_documents_hold_the_benchmark_targets() {
|
||||
for n in [10, 11, 12, 13, 14, 15] {
|
||||
let (doc, page) = super::large_document(n).unwrap();
|
||||
let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect();
|
||||
let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node));
|
||||
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle");
|
||||
assert!(has(&|x| x.name == "Title"), "{n}: no title");
|
||||
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> {
|
||||
let _ = doc.remove(te.id);
|
||||
s.ds_mut()?.selection.clear();
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
if empty && te.created {
|
||||
// Nothing to undo for a text box that was never filled.
|
||||
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
|
||||
// after the layer was added, so it still holds it: re-base it on the document without the
|
||||
// layer, and committing then records no step (and leaves the redo stack alone).
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(last) = ds.history.undo.last()
|
||||
&& *last.doc == *ds.doc
|
||||
{
|
||||
ds.history.undo.pop();
|
||||
let doc = ds.doc.clone();
|
||||
if let Some(it) = ds.interaction.as_mut() {
|
||||
it.doc = doc;
|
||||
}
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
let ds = s.ds_mut()?;
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
@@ -82,12 +83,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
|
||||
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
|
||||
/// characters dropped, so pasted plain text from any platform lands cleanly.
|
||||
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
let text: String = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.collect();
|
||||
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -146,9 +142,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
|
||||
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is).
|
||||
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
|
||||
/// at the deepest level stay as they are).
|
||||
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
|
||||
list_key(s, "Tab", out)
|
||||
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
|
||||
let before = text_of(s, id)?;
|
||||
if !list_key(s, "Tab", out)? {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(text_of(s, id)? != before)
|
||||
}
|
||||
|
||||
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
|
||||
|
||||
@@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
|
||||
let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
|
||||
let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone();
|
||||
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"}));
|
||||
ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true}));
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let source = s.page().unwrap();
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
|
||||
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
|
||||
_ => panic!("expected a page"),
|
||||
};
|
||||
let (flows, prototype, guides) = state(&s, copy);
|
||||
let (src_flows, src_prototype, src_guides) = state(&s, source);
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].name, "Checkout");
|
||||
assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame");
|
||||
assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own");
|
||||
assert_eq!(prototype, src_prototype);
|
||||
assert_eq!(prototype.device, "iPhone 16");
|
||||
assert!(prototype.responsive);
|
||||
assert_eq!(guides, src_guides);
|
||||
assert_eq!(guides.len(), 1);
|
||||
assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() {
|
||||
let mut s = Session::new();
|
||||
let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "object.wrapInSection", json!({"ids": [start]}));
|
||||
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"}));
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let section = doc.node(copy).unwrap().children[0];
|
||||
let frame = doc.node(section).unwrap().children[0];
|
||||
match &doc.node(copy).unwrap().kind {
|
||||
NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::<Vec<_>>(), vec![("Onboarding", frame)]),
|
||||
_ => panic!("expected a page"),
|
||||
}
|
||||
}
|
||||
@@ -1456,7 +1456,8 @@ impl Session {
|
||||
}
|
||||
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
|
||||
Gesture::PenDrag { anchor } => {
|
||||
let out = ev.p() - *anchor;
|
||||
// A click's wobble pulls out no handle.
|
||||
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
|
||||
if let Some(pen) = self.tool.pen.as_mut()
|
||||
&& let Some(last) = pen.points.last_mut()
|
||||
{
|
||||
@@ -1856,9 +1857,10 @@ impl Session {
|
||||
self.widget_click(ev.p(), ev.tol() * 2.0)?;
|
||||
return Ok(());
|
||||
}
|
||||
// The second press began a move or a resize: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
|
||||
// The second press began a move, a resize, a shape or a marquee: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) {
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.overlay.marquee = None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||||
|
||||
@@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() {
|
||||
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
|
||||
assert_eq!(scope(f, "visible"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_collection_refuses_a_taken_name() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let before = s.doc().unwrap().variables.collections.len();
|
||||
assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before);
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
fn tvec(t: (f64, f64)) -> Vec2 {
|
||||
@@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
|
||||
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
|
||||
let s = net.segments.get(seg).copied()?;
|
||||
let c: CubicBez = net.cubic(seg)?;
|
||||
// Which way each region loop walks `seg`, read before the split changes its endpoints.
|
||||
let reversed: Vec<Vec<bool>> = net
|
||||
.regions
|
||||
.iter()
|
||||
.map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect())
|
||||
.collect();
|
||||
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
|
||||
let l = c.subsegment(0.0..t);
|
||||
let r = c.subsegment(t..1.0);
|
||||
@@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usiz
|
||||
};
|
||||
net.segments.push(new_seg);
|
||||
let ni = net.segments.len() - 1;
|
||||
for reg in &mut net.regions {
|
||||
for lp in &mut reg.loops {
|
||||
for (r, reg) in net.regions.iter_mut().enumerate() {
|
||||
for (l, lp) in reg.loops.iter_mut().enumerate() {
|
||||
if let Some(pos) = lp.iter().position(|x| *x == seg) {
|
||||
lp.insert(pos + 1, ni);
|
||||
// Walked backwards, the new half (mid→b) comes before `seg` (a→mid).
|
||||
let rev = reversed.get(r).and_then(|v| v.get(l)).copied().unwrap_or(false);
|
||||
lp.insert(if rev { pos } else { pos + 1 }, ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,6 +774,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
|
||||
if ia { b } else { a }
|
||||
};
|
||||
let n = 64;
|
||||
// Upper bound on the curve's speed: no point of a sample interval is farther than the whole
|
||||
// interval length from either end, so only intervals that come that close need a closer look.
|
||||
let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max);
|
||||
let reach = speed / f64::from(n);
|
||||
// A crossing narrower than an interval whose ends are both outside: the closest point.
|
||||
let dip = |a: f64, b: f64| -> Option<f64> {
|
||||
if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach {
|
||||
return None;
|
||||
}
|
||||
let near = cb.subsegment(a..b).nearest(c, 1e-12);
|
||||
let m = a + (b - a) * near.t;
|
||||
(m > a && m < b && inside(m)).then_some(m)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
|
||||
let mut prev = 0.0;
|
||||
@@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
|
||||
let t = f64::from(i) / f64::from(n);
|
||||
match (start, inside(t)) {
|
||||
(None, true) => start = Some(edge(prev, t)),
|
||||
(None, false) => {
|
||||
if let Some(m) = dip(prev, t) {
|
||||
out.push((edge(prev, m), edge(m, t)));
|
||||
}
|
||||
}
|
||||
(Some(s0), false) => {
|
||||
out.push((s0, edge(prev, t)));
|
||||
start = None;
|
||||
@@ -856,6 +882,38 @@ mod tests {
|
||||
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
|
||||
let ends: Vec<(usize, usize)> = n
|
||||
.loop_walk(lp)
|
||||
.iter()
|
||||
.map(|&(i, rev)| {
|
||||
let s = n.segments[i];
|
||||
if rev { (s.b, s.a) } else { (s.a, s.b) }
|
||||
})
|
||||
.collect();
|
||||
(0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_region_loop_in_ring_order() {
|
||||
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
|
||||
let p = Point::new(25.0, 10.0);
|
||||
assert!(n.fill_contains(p));
|
||||
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
|
||||
let mut m = n.clone();
|
||||
m.regions[0].loops = vec![lp];
|
||||
let mid = split_segment(&mut m, seg, 0.5).unwrap();
|
||||
assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops);
|
||||
assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill");
|
||||
if seg == 1 {
|
||||
assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_mirroring() {
|
||||
let mut n = open_line();
|
||||
@@ -999,4 +1057,38 @@ mod tests {
|
||||
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
|
||||
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_catches_narrow_crossing_between_samples() {
|
||||
// A radius-1 brush on a long line: the crossing fits between the sampled parameters.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0));
|
||||
assert_eq!(n.segments.len(), 2);
|
||||
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
|
||||
assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}");
|
||||
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}");
|
||||
// A curved segment that only grazes the brush between samples.
|
||||
let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
c.segments[0].ta = (0.0, 3000.0);
|
||||
c.segments[0].tb = (0.0, 3000.0);
|
||||
let cb = c.cubic(0).unwrap();
|
||||
let t = 0.7039;
|
||||
let p = cb.eval(t);
|
||||
let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize();
|
||||
let normal = Vec2::new(-d.y, d.x);
|
||||
assert!(erase(&mut c, p + normal * 0.5, 1.0));
|
||||
assert_eq!(c.segments.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_catches_crossing_at_full_speed_between_samples() {
|
||||
// The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
let centre = n.cubic(0).unwrap().eval(32.5 / 64.0);
|
||||
assert!(erase(&mut n, centre, 0.5));
|
||||
assert_eq!(n.segments.len(), 2);
|
||||
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
|
||||
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
|
||||
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
|
||||
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_earlier_guide_keeps_the_selected_guide() {
|
||||
let mut s = Session::new();
|
||||
for offset in [100, 200, 300] {
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset}));
|
||||
}
|
||||
let page = s.page().unwrap();
|
||||
ex(&mut s, "guide.select", json!({"index": 1}));
|
||||
ex(&mut s, "guide.remove", json!({"index": 0}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index");
|
||||
assert!(s.tool_key("ArrowRight", false, false, false).unwrap());
|
||||
assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour");
|
||||
// A later guide leaves the selection alone.
|
||||
ex(&mut s, "guide.remove", json!({"index": 1}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_snap_and_measure() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
doc::NodeKind,
|
||||
tools::{PointerEvent, PointerKind},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn text_count(s: &Session) -> usize {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count()
|
||||
}
|
||||
|
||||
fn rect_count(s: &Session) -> usize {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s)
|
||||
}
|
||||
|
||||
fn click_and_abandon(s: &mut Session) {
|
||||
s.execute("tool.text", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
|
||||
assert_eq!(text_count(s), 1);
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(text_count(s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_empty_text_does_not_create_an_undo_step() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
click_and_abandon(&mut s);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_empty_text_keeps_the_previous_undo_step_first() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
click_and_abandon(&mut s);
|
||||
assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_count(&s), 0);
|
||||
assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_empty_text_keeps_the_redo_stack() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), (0, 1));
|
||||
click_and_abandon(&mut s);
|
||||
assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(rect_count(&s), 1);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::{NodeId, StyleKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn layer_paint(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
fn style_paint(s: &Session, id: &str) -> String {
|
||||
match &s.doc().unwrap().style(id).unwrap().kind {
|
||||
StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(),
|
||||
_ => panic!("expected paint style"),
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_library_paint_reaches_linked_layer() {
|
||||
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id())));
|
||||
let _ = std::fs::remove_dir_all(&fixture.0);
|
||||
std::fs::create_dir(&fixture.0).unwrap();
|
||||
let path = fixture.0.join("library.figcraft");
|
||||
let mut publisher = Session::new();
|
||||
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
|
||||
let publication = run(&mut publisher, "library.publish", json!({}));
|
||||
assert_eq!(publication["components"], 0);
|
||||
assert_eq!(publication["styles"], 1);
|
||||
run(&mut publisher, "file.saveAs", json!({"path":path}));
|
||||
let mut consumer = Session::new();
|
||||
let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40}));
|
||||
let layer = NodeId(rectangle["id"].as_u64().unwrap());
|
||||
run(&mut consumer, "library.add", json!({"path":path}));
|
||||
let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer}));
|
||||
let style = applied["style"].as_str().unwrap();
|
||||
assert_eq!(layer_paint(&consumer, layer), "0000FF");
|
||||
run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"}));
|
||||
run(&mut publisher, "library.publish", json!({}));
|
||||
run(&mut publisher, "file.save", json!({}));
|
||||
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1);
|
||||
let accepted = run(&mut consumer, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(accepted["styles"], 1);
|
||||
assert_eq!(accepted["components"], 0);
|
||||
assert_eq!(accepted["variables"], 0);
|
||||
assert_eq!(style_paint(&consumer, style), "00FF00");
|
||||
assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style));
|
||||
// Captured before any reapply so a later action can't hide a missed refresh.
|
||||
let after_accept = layer_paint(&consumer, layer);
|
||||
assert_eq!(after_accept, "00FF00");
|
||||
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0);
|
||||
consumer.undo().unwrap();
|
||||
assert_eq!(style_paint(&consumer, style), "0000FF");
|
||||
assert_eq!(layer_paint(&consumer, layer), "0000FF");
|
||||
consumer.redo().unwrap();
|
||||
assert_eq!(style_paint(&consumer, style), "00FF00");
|
||||
assert_eq!(layer_paint(&consumer, layer), "00FF00");
|
||||
run(&mut consumer, "style.apply", json!({"style":style,"id":layer}));
|
||||
assert_eq!(layer_paint(&consumer, layer), "00FF00");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
doc::{ListStyle, NodeId, NodeKind},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn editing_list(indent: u8) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
|
||||
let id = NodeId(value["id"].as_u64().unwrap());
|
||||
s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap();
|
||||
s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap();
|
||||
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) {
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") };
|
||||
let p = t.para_list(0);
|
||||
(p.list, p.indent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_at_cap_reports_false() {
|
||||
let (mut s, id) = editing_list(8);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let result = s.execute("text.listIndent", &json!({})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8));
|
||||
assert_eq!(before, *s.doc().unwrap());
|
||||
assert_eq!(result["changed"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_from_zero_reports_true() {
|
||||
let (mut s, id) = editing_list(0);
|
||||
let result = s.execute("text.listIndent", &json!({})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
|
||||
assert_eq!(result["changed"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdent_from_zero_reports_true() {
|
||||
let (mut s, id) = editing_list(0);
|
||||
let result = s.execute("text.listOutdent", &json!({})).unwrap();
|
||||
assert_eq!(list_state(&s, id).0, ListStyle::None);
|
||||
assert_eq!(result["changed"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_plain_text_reports_false() {
|
||||
let mut s = Session::new();
|
||||
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
|
||||
let id = NodeId(value["id"].as_u64().unwrap());
|
||||
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let result = s.execute("text.listIndent", &json!({})).unwrap();
|
||||
assert_eq!(before, *s.doc().unwrap());
|
||||
assert_eq!(result["changed"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_keys_still_nest_and_unnest() {
|
||||
let (mut s, id) = editing_list(0);
|
||||
s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
|
||||
s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn scopes(session: &Session, variable: &str) -> Option<Vec<String>> {
|
||||
session.doc().unwrap().variables.get(variable).unwrap().scopes.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_restores_all_supported_properties() {
|
||||
let mut session = Session::new();
|
||||
session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap();
|
||||
let variable = result["id"].as_str().unwrap();
|
||||
assert_eq!(scopes(&session, variable), None);
|
||||
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
|
||||
assert_eq!(scopes(&session, variable), Some(vec![]));
|
||||
session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap();
|
||||
assert_eq!(scopes(&session, variable), Some(vec![]));
|
||||
assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true}));
|
||||
assert_eq!(scopes(&session, variable), None);
|
||||
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
|
||||
session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap();
|
||||
assert_eq!(scopes(&session, variable), None);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rectangle(s: &mut Session, x: f64) -> Value {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_includes_outlines_for_no_op() {
|
||||
let mut s = Session::new();
|
||||
let id = rectangle(&mut s, 0.0);
|
||||
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(regions["regions"].as_array().unwrap().len(), 1);
|
||||
let before = serde_json::to_value(s.doc().unwrap()).unwrap();
|
||||
let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before);
|
||||
assert_eq!(result["id"], Value::Null);
|
||||
assert_eq!(result["changed"], json!([]));
|
||||
assert_eq!(result["removed"], json!([]));
|
||||
assert_eq!(result["regions"], json!([0]));
|
||||
assert_eq!(result.get("outlines"), Some(&json!([])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_control_includes_outlines() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, 0.0);
|
||||
let b = rectangle(&mut s, 50.0);
|
||||
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!(regions["regions"].as_array().unwrap().len(), 3);
|
||||
let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap();
|
||||
assert!(result["id"].is_u64());
|
||||
assert_eq!(result.get("outlines"), Some(&json!([])));
|
||||
assert_eq!(result.as_object().unwrap().len(), 5);
|
||||
}
|
||||
@@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() {
|
||||
assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_styled_layers_fill_variable_survives_a_round_trip() {
|
||||
let mut doc = Document::new("Overlap");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() };
|
||||
doc.variables.collections =
|
||||
vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }];
|
||||
let light = Color::new(0.1, 0.3, 0.7, 1.0);
|
||||
let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0));
|
||||
doc.variables.variables =
|
||||
vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])];
|
||||
doc.styles = vec![Style {
|
||||
id: "s-accent".into(),
|
||||
name: "Accent".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![Paint::solid(light)] },
|
||||
bound: Default::default(),
|
||||
}];
|
||||
add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| {
|
||||
n.fills = vec![Paint::solid(light)];
|
||||
n.styles.fill = Some("s-accent".into());
|
||||
n.bound.insert("fill".into(), "v-accent".into());
|
||||
});
|
||||
let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap();
|
||||
let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone();
|
||||
let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap();
|
||||
let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var);
|
||||
let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap();
|
||||
let on_style = style.bound.get("fills/0/color") == Some(&var);
|
||||
assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_on_part_of_a_text_are_written_as_values() {
|
||||
let mut doc = styled();
|
||||
|
||||
@@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a layer sizes and sits in its auto layout parent.
|
||||
/// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own
|
||||
/// and are written whatever its parent is.
|
||||
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
|
||||
let cl = &n.child_layout;
|
||||
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
|
||||
if w.is_some() || h.is_some() {
|
||||
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
|
||||
}
|
||||
}
|
||||
let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return };
|
||||
let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill);
|
||||
let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) };
|
||||
@@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> {
|
||||
if cl.absolute {
|
||||
f.push(("stackPositioning", e("ABSOLUTE")));
|
||||
}
|
||||
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
|
||||
if w.is_some() || h.is_some() {
|
||||
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
|
||||
}
|
||||
}
|
||||
if pl.mode == LayoutMode::Grid
|
||||
&& let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id)))
|
||||
{
|
||||
@@ -1551,6 +1552,10 @@ impl<'d> Exporter<'d> {
|
||||
.map(|(st, id)| {
|
||||
let mut ef: Fields = vec![("styleID", u(id))];
|
||||
style_fields(st, &mut ef);
|
||||
// An empty link clears the text's own (a missing one would inherit it).
|
||||
if st.link.is_none() && td.style.link.is_some() {
|
||||
ef.push(("hyperlink", o(vec![("url", s(""))])));
|
||||
}
|
||||
if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) {
|
||||
ef.push(("fillPaints", self.paints(p, true, &Bound::default())));
|
||||
}
|
||||
|
||||
@@ -1014,6 +1014,37 @@ fn junk_documents_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's own min/max size survives whatever its parent is, not only under auto layout.
|
||||
#[test]
|
||||
fn frame_size_limits_survive_without_auto_layout_parent() {
|
||||
fn limits(doc: &Document) -> [Option<f64>; 4] {
|
||||
let (bytes, ew) = export(doc, None).unwrap();
|
||||
let (back, iw) = import(&bytes, "File").unwrap();
|
||||
assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}");
|
||||
let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout;
|
||||
[cl.min_w, cl.max_w, cl.min_h, cl.max_h]
|
||||
}
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let node = doc.node_mut(id).unwrap();
|
||||
node.layout = Some(AutoLayout::default());
|
||||
node.child_layout.min_w = Some(80.0);
|
||||
node.child_layout.max_w = Some(150.0);
|
||||
node.child_layout.min_h = Some(60.0);
|
||||
node.child_layout.max_h = Some(170.0);
|
||||
let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)];
|
||||
|
||||
let mut nested = doc.clone();
|
||||
let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap();
|
||||
nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default());
|
||||
nested.reparent(id, wrapper, None).unwrap();
|
||||
assert_eq!(limits(&nested), expected, "auto layout parent");
|
||||
nested.node_mut(wrapper).unwrap().layout = None;
|
||||
assert_eq!(limits(&nested), expected, "plain frame parent");
|
||||
assert_eq!(limits(&doc), expected, "page parent");
|
||||
}
|
||||
|
||||
/// Link each instance's layers to its main's by position, as they are imported (the engine
|
||||
/// links them by path after loading).
|
||||
fn link(doc: &mut Document) {
|
||||
|
||||
@@ -643,13 +643,17 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
let t = v.transform().unwrap_or_default();
|
||||
o.insert("relativeTransform".into(), rows(space * t));
|
||||
// Paints, from the shared style when one is linked (the layer's own copy can be stale).
|
||||
// Paints, from the shared style when one is linked (the layer's own copy can be stale),
|
||||
// unless the layer's own paints are bound to variables: those bindings must not be lost.
|
||||
let mut styles = Obj::new();
|
||||
let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound));
|
||||
let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles);
|
||||
let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
|
||||
let fills = own("fillPaints")
|
||||
.unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
|
||||
o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect()));
|
||||
let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles);
|
||||
let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
|
||||
let strokes = own("strokePaints")
|
||||
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
|
||||
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect()));
|
||||
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
|
||||
let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
|
||||
@@ -1564,6 +1568,11 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a paint's colour or one of its gradient stops is bound to a variable.
|
||||
fn paint_bound(p: &Kv) -> bool {
|
||||
alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some())
|
||||
}
|
||||
|
||||
fn paint(p: &Kv) -> Option<Value> {
|
||||
let ty = p.get("type").and_then(Kv::str)?;
|
||||
let mut o = Obj::new();
|
||||
@@ -1753,7 +1762,8 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) {
|
||||
// An empty URL is kept: on an override entry it clears the text's own link.
|
||||
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) {
|
||||
s.insert("hyperlink".into(), json!({"type": "URL", "url": u}));
|
||||
}
|
||||
s
|
||||
|
||||
@@ -126,7 +126,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
|
||||
}
|
||||
if let Some(h) = s.get("hyperlink") {
|
||||
match (text(h, "type"), text(h, "url")) {
|
||||
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()),
|
||||
// The destination is data, not a label: `clip_str` is for names that go into warnings,
|
||||
// and clipping a URL changed it into a different, working-looking one without a word
|
||||
// (#459). The FIG importer already keeps it whole.
|
||||
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()),
|
||||
// An empty URL clears the link the base style has.
|
||||
(Some("URL") | None, Some(_)) => t.link = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! A text hyperlink's destination survives a REST import whole, however long it is (#459).
|
||||
//!
|
||||
//! The assertions are the reporter's; the helper returns its findings instead of unwrapping, so
|
||||
//! that the crate's `unwrap_used`/`panic` lints hold outside a `#[test]` function.
|
||||
|
||||
use figcraft_doc::NodeKind;
|
||||
use figcraft_format::rest;
|
||||
use serde_json::json;
|
||||
|
||||
/// The link of the imported text, and the warnings the import reported. `None` for a link means
|
||||
/// the layer was missing, was not text, or carried no link at all.
|
||||
fn imported_link(length: usize, override_style: bool) -> (String, Option<String>, Vec<String>) {
|
||||
let url = format!("https://example.com/{}", "a".repeat(length - 20));
|
||||
assert_eq!(url.len(), length);
|
||||
let mut text = json!({"id":"1:2","type":"TEXT","name":"Link","characters":"A","relativeTransform":[[1,0,0],[0,1,0]]});
|
||||
let style = json!({"hyperlink":{"type":"URL","url":url}});
|
||||
if override_style {
|
||||
text["characterStyleOverrides"] = json!([1]);
|
||||
text["styleOverrideTable"] = json!({"1":style});
|
||||
} else {
|
||||
text["style"] = style;
|
||||
}
|
||||
let input = json!({"document":{"type":"DOCUMENT","children":[{"id":"1:1","type":"CANVAS","children":[text]}]}});
|
||||
assert!(rest::is_rest_json(&input));
|
||||
let Ok((doc, warnings)) = rest::import(&input, &rest::no_images) else {
|
||||
return (url, None, vec!["import failed".into()]);
|
||||
};
|
||||
let link = match doc.nodes().find(|n| n.name == "Link").map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(data)) if override_style => data.runs.first().and_then(|r| r.style.link.clone()),
|
||||
Some(NodeKind::Text(data)) => data.style.link.clone(),
|
||||
_ => None,
|
||||
};
|
||||
(url, link, warnings)
|
||||
}
|
||||
|
||||
/// Every length in turn: the destination comes back exactly, and nothing is reported.
|
||||
fn check(override_style: bool) {
|
||||
let mut mismatches = Vec::new();
|
||||
for length in [200, 201, 240] {
|
||||
let (url, link, warnings) = imported_link(length, override_style);
|
||||
assert!(warnings.is_empty(), "import warned at {length}: {warnings:?}");
|
||||
if link.as_deref() != Some(url.as_str()) {
|
||||
mismatches.push((length, link.map_or(0, |l| l.len())));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "URLs changed, (input length, output length): {mismatches:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_hyperlink_preserves_url() {
|
||||
check(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_hyperlink_preserves_url() {
|
||||
check(true);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! A hyperlink cleared on part of a text layer stays cleared through FIG export and import.
|
||||
|
||||
use figcraft_doc::{Document, NodeKind, TextData};
|
||||
use figcraft_format::fig;
|
||||
|
||||
fn links(doc: &Document, name: &str) -> Vec<Option<String>> {
|
||||
let Some(NodeKind::Text(text)) = doc.nodes().find(|n| n.name == name).map(|n| &n.kind) else { return Vec::new() };
|
||||
text.spans().into_iter().flat_map(|(start, end, style)| std::iter::repeat_n(style.link.clone(), end - start)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_range_hyperlink_round_trip() {
|
||||
let mut document = Document::new("File");
|
||||
let page = document.first_page().unwrap();
|
||||
let url = "https://example.com/".to_string();
|
||||
|
||||
let mut text = TextData { characters: "AB".into(), ..Default::default() };
|
||||
text.style.link = Some(url.clone());
|
||||
text.restyle(1, 2, |style| style.link = None);
|
||||
document.create(page, NodeKind::Text(text), "Cleared", 0.0, 0.0, 100.0, 30.0).unwrap();
|
||||
|
||||
let mut control = TextData { characters: "AB".into(), ..Default::default() };
|
||||
control.restyle(1, 2, |style| style.link = Some(url.clone()));
|
||||
document.create(page, NodeKind::Text(control), "Explicit", 0.0, 40.0, 100.0, 30.0).unwrap();
|
||||
|
||||
let expected = vec![Some(url.clone()), None];
|
||||
assert_eq!(links(&document, "Cleared"), expected);
|
||||
let native_bytes = figcraft_format::save_bytes(&document, None).unwrap();
|
||||
let native = figcraft_format::load_bytes(&native_bytes).unwrap().0;
|
||||
assert_eq!(links(&native, "Cleared"), expected);
|
||||
|
||||
let (bytes, export_warnings) = fig::export(&document, None).unwrap();
|
||||
let (loaded, import_warnings) = fig::import(&bytes, "File").unwrap();
|
||||
assert!(export_warnings.is_empty(), "{export_warnings:?}");
|
||||
assert!(import_warnings.is_empty(), "{import_warnings:?}");
|
||||
assert_eq!(links(&loaded, "Explicit"), vec![None, Some(url)]);
|
||||
assert_eq!(links(&loaded, "Cleared"), expected);
|
||||
}
|
||||
+13
-2
@@ -665,8 +665,19 @@ fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult,
|
||||
let side = (x1 - x0).max(y1 - y0).max(1e-6);
|
||||
scale = scale.min(max / side);
|
||||
}
|
||||
let bytes = b.render_png(&ids, scale)?;
|
||||
let (w, h) = png_info(&bytes);
|
||||
let mut bytes = b.render_png(&ids, scale)?;
|
||||
let (mut w, mut h) = png_info(&bytes);
|
||||
// Layer bounds leave out effects (shadows) and overflow of unclipped frames, but the render
|
||||
// includes them: shrink to the measured image until it fits `maxSize` (rounding can add a pixel).
|
||||
for _ in 0..4 {
|
||||
let side = f64::from(w.max(h));
|
||||
if side <= max {
|
||||
break;
|
||||
}
|
||||
scale *= max / side;
|
||||
bytes = b.render_png(&ids, scale)?;
|
||||
(w, h) = png_info(&bytes);
|
||||
}
|
||||
Ok(ToolResult::image(
|
||||
&bytes,
|
||||
"image/png",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Hidden top-level layers in an export take no room in the image (issue #447).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Client, png_dims};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn dimensions(result: Value) -> (u32, u32) {
|
||||
assert_eq!(result["isError"], false, "{result}");
|
||||
let image = result["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap();
|
||||
png_dims(image["data"].as_str().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_root_does_not_expand_export() {
|
||||
let mut client = Client::new();
|
||||
let visible = client.create(json!({
|
||||
"type":"frame", "x":0, "y":0, "width":100, "height":100,
|
||||
"props":{"fill":"#FF0000"}
|
||||
}));
|
||||
let hidden = client.create(json!({
|
||||
"type":"frame", "x":900, "y":0, "width":300, "height":300,
|
||||
"props":{"visible":false}
|
||||
}));
|
||||
assert_eq!(client.node(&hidden)["visible"], false);
|
||||
|
||||
let control = dimensions(client.call("export", json!({"ids":[visible], "format":"png", "size":"200w"})));
|
||||
assert_eq!(control, (200, 200));
|
||||
let mixed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "size":"200w"})));
|
||||
assert_eq!(mixed, control, "hidden roots must not enlarge the painted area");
|
||||
let natural = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png"})));
|
||||
assert_eq!(natural, (100, 100));
|
||||
|
||||
client.ok("select", json!({"ids":[visible, hidden]}));
|
||||
let selected = dimensions(client.call("export", json!({"format":"png", "size":"200w"})));
|
||||
assert_eq!(selected, (200, 200));
|
||||
client.ok("select", json!({"ids":[]}));
|
||||
let page = dimensions(client.call("export", json!({"format":"png"})));
|
||||
assert_eq!(page, (100, 100));
|
||||
|
||||
// Controls: the bounding-box option and a hidden-only export keep their areas.
|
||||
let boxed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "includeBoundingBox":true})));
|
||||
assert_eq!(boxed, (1200, 300));
|
||||
let alone = dimensions(client.call("export", json!({"ids":[hidden], "format":"png"})));
|
||||
assert_eq!(alone, (300, 300));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `screenshot` with `ids` must honour `maxSize` for the rendered paint area (shadows, overflow).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod common;
|
||||
use common::{Client, png_dims};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn dimensions(client: &mut Client, args: Value) -> (u32, u32) {
|
||||
let result = client.call("screenshot", args);
|
||||
assert_eq!(result["isError"], false, "{result}");
|
||||
let size = png_dims(result["content"][0]["data"].as_str().unwrap());
|
||||
let meta: Value = serde_json::from_str(result["content"][1]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(meta["width"], size.0, "{meta}");
|
||||
assert_eq!(meta["height"], size.1, "{meta}");
|
||||
size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_screenshots_fit_the_requested_size() {
|
||||
let mut shadow_client = Client::new();
|
||||
let shadow = shadow_client.create(json!({
|
||||
"type": "rectangle", "x": 0, "y": 0,
|
||||
"width": 100, "height": 100,
|
||||
"props": {"effects": [{
|
||||
"type": "dropShadow", "color": "#00000080",
|
||||
"offset": [0, 40], "radius": 60
|
||||
}]}
|
||||
}));
|
||||
let shadow_size = dimensions(&mut shadow_client, json!({"ids": [shadow], "maxSize": 100}));
|
||||
|
||||
let mut frame_client = Client::new();
|
||||
let frame = frame_client.create(json!({
|
||||
"type": "frame", "x": 0, "y": 0,
|
||||
"width": 100, "height": 100, "props": {"clip": false}
|
||||
}));
|
||||
frame_client.create(json!({
|
||||
"type": "rectangle", "parent": frame,
|
||||
"x": 900, "y": 900, "width": 100, "height": 100
|
||||
}));
|
||||
let frame_size = dimensions(&mut frame_client, json!({"ids": [frame], "maxSize": 100}));
|
||||
|
||||
assert!(shadow_size.0 <= 100 && shadow_size.1 <= 100, "shadow screenshot exceeds maxSize: {shadow_size:?}");
|
||||
assert!(shadow_size.0 >= 90 && shadow_size.1 >= 90, "shadow screenshot shrank too far: {shadow_size:?}");
|
||||
assert!(frame_size.0 <= 100 && frame_size.1 <= 100, "frame screenshot exceeds maxSize: {frame_size:?}");
|
||||
assert!(frame_size.0 >= 90 && frame_size.1 >= 90, "frame screenshot shrank too far: {frame_size:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_layer_screenshot_sizes_are_unchanged() {
|
||||
let mut client = Client::new();
|
||||
let rect = client.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100}));
|
||||
assert_eq!(dimensions(&mut client, json!({"ids": [rect], "maxSize": 100})), (100, 100));
|
||||
assert_eq!(dimensions(&mut client, json!({"ids": [rect], "scale": 2})), (200, 200));
|
||||
}
|
||||
@@ -35,6 +35,8 @@ pub(crate) struct Exporter<'a> {
|
||||
pub links: Vec<(Rect, String)>,
|
||||
/// Above zero while drawing into a soft mask (nothing there is visible or clickable).
|
||||
pub(crate) masking: usize,
|
||||
/// Above zero while drawing a pattern tile (links and backdrops belong to the page).
|
||||
pub(crate) tiling: usize,
|
||||
/// The page's frame and the bleed its fills and clip reach out to.
|
||||
pub bleed_root: Option<(NodeId, f64)>,
|
||||
pub(crate) fonts: HashMap<u32, Option<krilla::text::Font>>,
|
||||
@@ -215,6 +217,7 @@ impl<'a> Exporter<'a> {
|
||||
page_ids: Vec::new(),
|
||||
links: Vec::new(),
|
||||
masking: 0,
|
||||
tiling: 0,
|
||||
bleed_root: None,
|
||||
fonts: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
@@ -246,7 +249,8 @@ impl<'a> Exporter<'a> {
|
||||
let op = n.opacity.clamp(0.0, 1.0);
|
||||
// Background blur and glass: an image drawn from what lies under the node, under its
|
||||
// content (nothing in a soft mask is seen).
|
||||
let back = if !as_mask && self.masking == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None };
|
||||
let back =
|
||||
if !as_mask && self.masking == 0 && self.tiling == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None };
|
||||
if fx.iter().any(|k| matches!(k, EffectKind::LayerBlur { .. } | EffectKind::Noise(_) | EffectKind::Texture(_))) {
|
||||
// Effects that change every pixel: the renderer draws the whole node.
|
||||
if let Some(b) = &back {
|
||||
@@ -521,14 +525,14 @@ impl<'a> Exporter<'a> {
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ps = sb.surface();
|
||||
// Links and backdrops belong to the page, not to a tile.
|
||||
self.masking += 1;
|
||||
self.tiling += 1;
|
||||
let _ = figcraft_render::pattern::nested(|| {
|
||||
for (tx, ty) in &cell.tiles {
|
||||
self.node(&mut ps, src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0, false);
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
self.masking -= 1;
|
||||
self.tiling -= 1;
|
||||
ps.finish();
|
||||
sb.finish()
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
|
||||
if !affine_ok(gxf) {
|
||||
return;
|
||||
}
|
||||
if ex.masking == 0 {
|
||||
if ex.masking == 0 && ex.tiling == 0 {
|
||||
record_links(ex, &layout, gxf);
|
||||
}
|
||||
let (w, h) = (n.width, n.height);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! CMYK export converts the colours inside vector pattern tiles (#426).
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind, PrintSettings};
|
||||
use figcraft_pdf::{PdfOptions, export_pdf};
|
||||
use std::io::Read;
|
||||
|
||||
/// Every zlib stream of `pdf`, inflated and joined.
|
||||
fn streams(pdf: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = pdf;
|
||||
while let Some(i) = rest.windows(7).position(|w| w == b"stream\n") {
|
||||
rest = &rest[i + 7..];
|
||||
let end = rest.windows(9).position(|w| w == b"endstream").unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
if flate2::read::ZlibDecoder::new(&rest[..end]).read_to_end(&mut bytes).is_ok() {
|
||||
out.push_str(&String::from_utf8_lossy(&bytes));
|
||||
out.push('\n');
|
||||
}
|
||||
rest = &rest[end + 9..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn export(pattern: bool, cmyk: bool) -> (String, String) {
|
||||
let mut doc = Document::new("fixture");
|
||||
let page = doc.first_page().unwrap();
|
||||
let source = doc.create(page, NodeKind::Rectangle, "Source", 40.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let green = Paint::solid(Color::from_hex("00FF00").unwrap());
|
||||
doc.get_mut(source).unwrap().fills = vec![green.clone()];
|
||||
let target = doc.create(page, NodeKind::Rectangle, "Target", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
doc.get_mut(target).unwrap().fills = vec![if pattern { Paint::pattern(source.0) } else { green }];
|
||||
let opts = PdfOptions { print: PrintSettings { cmyk, ..Default::default() }, ..Default::default() };
|
||||
let pdf = export_pdf(&doc, &[target], &opts).unwrap();
|
||||
(String::from_utf8_lossy(&pdf).into_owned(), streams(&pdf))
|
||||
}
|
||||
|
||||
fn has_rgb_op(content: &str) -> bool {
|
||||
content.split_whitespace().any(|op| op == "rg" || op == "RG")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_cmyk_controls() {
|
||||
let (_, solid) = export(false, true);
|
||||
assert!(solid.contains("1 0 1 0 k"), "{solid}");
|
||||
assert!(!has_rgb_op(&solid), "{solid}");
|
||||
let (pdf, pattern) = export(true, false);
|
||||
assert!(pdf.contains("/PatternType 1"));
|
||||
assert!(pattern.contains("0 1 0 rg"), "{pattern}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_colors_follow_cmyk_setting() {
|
||||
let (pdf, content) = export(true, true);
|
||||
assert!(pdf.contains("/PatternType 1"));
|
||||
assert!(!has_rgb_op(&content), "RGB operator remains in CMYK pattern: {content}");
|
||||
assert!(content.contains("1 0 1 0 k"), "green must convert to CMYK: {content}");
|
||||
}
|
||||
@@ -735,13 +735,17 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
|
||||
// layers behind the frame.
|
||||
let Some(mut layer) = pix.clone_rect(ir) else { return };
|
||||
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
|
||||
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
|
||||
// The clip (a rectangular one is the layer's own box) is known before the children draw, so
|
||||
// their backdrops are clipped the way the layer will be.
|
||||
let square_aligned = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
|
||||
let rect_clip = square_aligned && on_whole_pixels(dev);
|
||||
// The clip (a rectangular one on whole pixels is the layer's own box) is known before the
|
||||
// children draw, so their backdrops are clipped the way the layer will be. An axis-aligned
|
||||
// square frame with fractional edges gets an anti-aliased mask of its box so its edge pixels
|
||||
// are only partly covered.
|
||||
let mask = if rect_clip {
|
||||
None
|
||||
} else {
|
||||
clip.and_then(ts_path).and_then(|cp| {
|
||||
let own_box = (square_aligned && clip.is_none()).then(|| n.local_rect().to_path(0.1));
|
||||
clip.or(own_box.as_ref()).and_then(ts_path).and_then(|cp| {
|
||||
let mut m = Mask::new(ir.width(), ir.height())?;
|
||||
m.fill_path(&cp, FillRule::Winding, true, ts_transform(lxf));
|
||||
Some(m)
|
||||
@@ -782,3 +786,9 @@ fn is_axis_aligned(xf: Affine) -> bool {
|
||||
fn fract_ok(_v: f64) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether all four edges of the device rectangle `r` lie on whole pixels, up to the float noise
|
||||
/// `snap_px` forgives.
|
||||
fn on_whole_pixels(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].into_iter().all(|v| (snap_px(v) - v.round()).abs() < 1e-9)
|
||||
}
|
||||
@@ -377,3 +377,29 @@ fn exports_include_what_effects_paint_outside_the_layer() {
|
||||
d.get_mut(f).unwrap().effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))];
|
||||
assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported");
|
||||
}
|
||||
|
||||
/// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×.
|
||||
fn blurred_frame(radius: f64) -> Pixmap {
|
||||
let mut d = Document::new("Blur range");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "Square", 156.0, 156.0, 200.0, 200.0).unwrap();
|
||||
let n = d.get_mut(f).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::BLACK)];
|
||||
n.effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(radius)))];
|
||||
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 512, height: 512 };
|
||||
render_view(&d, p, &view, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_blur_radii_up_to_the_documented_limit_all_draw() {
|
||||
// The documented blur range runs to 10,000: a larger radius in it blurs further, it doesn't
|
||||
// stop at some smaller one.
|
||||
let differ = |a: &Pixmap, b: &Pixmap| a.data().chunks_exact(4).zip(b.data().chunks_exact(4)).filter(|(a, b)| a != b).count();
|
||||
let sharp = blurred_frame(0.0);
|
||||
let small = blurred_frame(100.0);
|
||||
let medium = blurred_frame(1_000.0);
|
||||
let large = blurred_frame(10_000.0);
|
||||
assert!(differ(&sharp, &small) > 0, "radius 0 draws the sharp shape");
|
||||
assert!(differ(&small, &medium) > 0, "100 vs 1000");
|
||||
assert!(differ(&medium, &large) > 0, "radii 1000 and 10000 draw the same pixels");
|
||||
}
|
||||
@@ -70,8 +70,11 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest blur standard deviation drawn (pixels): wider blurs look the same.
|
||||
pub const MAX_SIGMA: f64 = 500.0;
|
||||
/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384
|
||||
/// pixels a side), so wider blurs look the same (everything averages out to nothing). The box
|
||||
/// passes cost the same at any radius, so the documented blur radii (up to 10,000 units) draw
|
||||
/// in full at any zoom.
|
||||
pub const MAX_SIGMA: f64 = 1_000_000.0;
|
||||
|
||||
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
|
||||
pub fn blur(p: &mut Pixmap, sigma: f64) {
|
||||
|
||||
+55
-13
@@ -177,19 +177,34 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
|
||||
})
|
||||
}
|
||||
|
||||
/// The furthest in (pixels) the rim light is worth half a level or more: where
|
||||
/// `255 × intensity × e^(−d / band)` falls to 0.5, at most [`MAX_LIGHT_PX`] (0 for no light).
|
||||
fn light_reach(band: f64, intensity: f64) -> f64 {
|
||||
let k = 255.0 * intensity / 0.5;
|
||||
if k > 1.0 && band.is_finite() { (band * k.ln()).min(MAX_LIGHT_PX) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The light reaches no further in than this many pixels (a wide light on a deep pane at a high
|
||||
/// zoom would otherwise need a huge margin of coverage around every cut).
|
||||
const MAX_LIGHT_PX: f64 = 512.0;
|
||||
|
||||
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
|
||||
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option<Pixmap> {
|
||||
let g = g.sanitized();
|
||||
let depth = (g.depth * scale).clamp(1.0, 1024.0);
|
||||
// Coverage with a margin, so distances aren't cut short where the layer is cropped (past the
|
||||
// margin the edge is further than `depth` away and nothing bends).
|
||||
let m = depth.ceil() as u32 + 2;
|
||||
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
|
||||
let reach = light_reach(band, g.light_intensity);
|
||||
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
|
||||
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
|
||||
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
|
||||
// smoothed from (2r + 1 further).
|
||||
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
|
||||
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
|
||||
let dist = inside_distance(&cov);
|
||||
let (cw, ch) = (cov.width() as usize, cov.height() as usize);
|
||||
let normals = smooth_normals(&dist, cw, ch, (depth * 0.1).clamp(1.0, 8.0) as usize);
|
||||
let normals = smooth_normals(&dist, cw, ch, r);
|
||||
let mut frosted = back.clone();
|
||||
fx::blur(&mut frosted, g.frost / 2.0 * scale);
|
||||
let ang = g.light_angle.to_radians();
|
||||
@@ -197,7 +212,11 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
|
||||
let bend_max = (g.refraction * depth) as f32;
|
||||
let fringe = (g.dispersion * 0.25) as f32;
|
||||
let power = (6.0 - 5.0 * g.splay) as f32;
|
||||
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0) as f32;
|
||||
let (band, reach) = (band as f32, reach as f32);
|
||||
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
|
||||
// fades out to 0 at `reach` instead of stopping there.
|
||||
let floor = (-reach / band).exp();
|
||||
let fall = 1.0 - floor;
|
||||
let intensity = g.light_intensity as f32;
|
||||
let depth = depth as f32;
|
||||
let mut out = Pixmap::new(w, h)?;
|
||||
@@ -216,14 +235,26 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
|
||||
let t = (d / depth).min(1.0);
|
||||
let bend = bend_max * (1.0 - t) * (1.0 - t);
|
||||
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
|
||||
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
|
||||
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
|
||||
let a = rs[3].max(gs[3]).max(bs[3]);
|
||||
let base = [rs[0].min(a), gs[1].min(a), bs[2].min(a), a];
|
||||
// The rim catches the light where it faces it, and half as much opposite.
|
||||
let facing = -(inward.0 * light.0 + inward.1 * light.1);
|
||||
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
|
||||
let hl = (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0);
|
||||
let base = if bend == 0.0 {
|
||||
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
|
||||
// at the same point, so one sample.
|
||||
let s = sample(&frosted, bx, by);
|
||||
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
|
||||
} else {
|
||||
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
|
||||
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
|
||||
let a = rs[3].max(gs[3]).max(bs[3]);
|
||||
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
|
||||
};
|
||||
// The rim catches the light where it faces it, and half as much opposite. It fades out
|
||||
// by `reach` and is none past it, wherever the layer is cut.
|
||||
let hl = if d < reach && fall > 0.0 {
|
||||
let facing = -(inward.0 * light.0 + inward.1 * light.1);
|
||||
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
|
||||
(intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let i = (y * w as usize + x) * 4;
|
||||
if let Some(p) = dst.get_mut(i..i + 4) {
|
||||
for (o, v) in p.iter_mut().zip(base) {
|
||||
@@ -252,4 +283,15 @@ mod tests {
|
||||
// A pixel on the edge is half a pixel from it.
|
||||
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_reach_is_where_the_light_falls_under_half_a_level() {
|
||||
let r = light_reach(10.0, 1.0);
|
||||
assert!((255.0 * (-r / 10.0).exp() - 0.5).abs() < 1e-9, "{r}");
|
||||
// No light, too faint to show, or a band that isn't a number: it reaches nowhere.
|
||||
for (band, intensity) in [(10.0, 0.0), (10.0, 0.5 / 255.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0)] {
|
||||
assert_eq!(light_reach(band, intensity), 0.0, "{band} {intensity}");
|
||||
}
|
||||
assert_eq!(light_reach(1e6, 1.0), MAX_LIGHT_PX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Glass render timings: a 600 × 360 rounded glass pane over coloured stripes, 2400 × 1600 device
|
||||
//! pixels. Run in release:
|
||||
//! `cargo test --release -p figcraft-render --lib glass_render_timings -- --ignored --nocapture`.
|
||||
//!
|
||||
//! "export" rows render the whole view with `render_view` (exports, thumbnails, the first canvas
|
||||
//! frame); "canvas" rows go through `Retained`, which re-renders only what changed, as the canvas
|
||||
//! does; "single" renders without parallel bands (as on the web).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
|
||||
use figcraft_geom::Affine;
|
||||
|
||||
use crate::{Options, Retained, View, Work, render_view, render_view_single};
|
||||
|
||||
const RUNS: u32 = 8;
|
||||
|
||||
/// Stripes, a card under the pane and the glass pane: (document, page, pane, card).
|
||||
fn scene() -> (Document, NodeId, NodeId, NodeId) {
|
||||
let mut d = Document::new("glass bench");
|
||||
let p = d.first_page().unwrap();
|
||||
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
|
||||
for i in 0..24u32 {
|
||||
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
|
||||
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
|
||||
}
|
||||
let card = d.create(p, NodeKind::Rectangle, "Card", 420.0, 300.0, 160.0, 120.0).unwrap();
|
||||
d.get_mut(card).unwrap().fills = vec![Paint::solid(Color::WHITE)];
|
||||
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.transform = Affine::translate((300.0, 220.0));
|
||||
n.corners.radii = [40.0; 4];
|
||||
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(Glass::default()), visible: true }];
|
||||
(d, p, pane, card)
|
||||
}
|
||||
|
||||
fn view(origin: (f64, f64), scale: f64) -> View {
|
||||
View { origin, scale, width: 2400, height: 1600 }
|
||||
}
|
||||
|
||||
/// Mean milliseconds of `RUNS` whole-view renders (`render` of the document and view `step(i)`
|
||||
/// leaves) after one warm-up.
|
||||
fn time(
|
||||
d: &mut Document,
|
||||
p: NodeId,
|
||||
render: fn(&Document, NodeId, &View, &Options) -> Option<tiny_skia::Pixmap>,
|
||||
mut step: impl FnMut(&mut Document, u32) -> View,
|
||||
) -> f64 {
|
||||
let v = step(d, 0);
|
||||
let _ = render(d, p, &v, &Options::default());
|
||||
let t = Instant::now();
|
||||
for i in 1..=RUNS {
|
||||
let v = step(d, i);
|
||||
assert!(render(d, p, &v, &Options::default()).is_some());
|
||||
}
|
||||
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
|
||||
}
|
||||
|
||||
/// Mean milliseconds of `RUNS` canvas frames at `scale`, each after `edit(document, i)`.
|
||||
fn canvas(d: &Document, p: NodeId, scale: f64, edit: impl Fn(&mut Document, u32)) -> f64 {
|
||||
let mut r = Retained::default();
|
||||
let mut doc = Arc::new(d.clone());
|
||||
let v = view((0.0, 0.0), scale);
|
||||
let _ = r.render(&doc, p, &v, &Options::default());
|
||||
let t = Instant::now();
|
||||
for i in 1..=RUNS {
|
||||
let mut next = (*doc).clone();
|
||||
edit(&mut next, i);
|
||||
doc = Arc::new(next);
|
||||
let (_, _, work) = r.render(&doc, p, &v, &Options::default()).unwrap();
|
||||
assert!(matches!(work, Work::Partial { .. }), "{work:?}");
|
||||
}
|
||||
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
|
||||
}
|
||||
|
||||
fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
|
||||
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing: run in release with --ignored --nocapture"]
|
||||
fn glass_render_timings() {
|
||||
let (mut d, p, pane, card) = scene();
|
||||
let move_card = move |d: &mut Document, i: u32| d.get_mut(card).unwrap().transform = Affine::translate((420.0 + 4.0 * f64::from(i), 300.0));
|
||||
let move_pane = move |d: &mut Document, i: u32| d.get_mut(pane).unwrap().transform = Affine::translate((300.0 + 3.0 * f64::from(i), 220.0));
|
||||
let rows: Vec<(&str, f64)> = vec![
|
||||
("export: same view again", time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0))),
|
||||
(
|
||||
"export: move the card under the glass",
|
||||
time(&mut d, p, render_view, |d, i| {
|
||||
move_card(d, i);
|
||||
view((0.0, 0.0), 2.0)
|
||||
}),
|
||||
),
|
||||
("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))),
|
||||
("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))),
|
||||
("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)),
|
||||
(
|
||||
"canvas: drag a glass slider (light angle)",
|
||||
canvas(&d, p, 2.0, |d, i| set_glass(d, pane, Glass { light_angle: -45.0 + f64::from(i), ..Glass::default() })),
|
||||
),
|
||||
("canvas: drag the pane (zoom 200%)", canvas(&d, p, 2.0, move_pane)),
|
||||
("canvas: drag the pane (zoom 137%)", canvas(&d, p, 1.37, move_pane)),
|
||||
(
|
||||
"canvas: resize the pane",
|
||||
canvas(&d, p, 2.0, |d, i| {
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.width = 600.0 + 3.0 * f64::from(i);
|
||||
}),
|
||||
),
|
||||
];
|
||||
d.get_mut(pane).unwrap().effects.iter_mut().for_each(|e| e.visible = false);
|
||||
let without = time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0));
|
||||
println!("glass render timings ({} build, 2400 × 1600 px, mean of {RUNS}, ms)", if cfg!(debug_assertions) { "debug" } else { "release" });
|
||||
println!("| render | ms |\n|---|---:|");
|
||||
for (name, ms) in rows {
|
||||
println!("| {name} | {ms:.1} |");
|
||||
}
|
||||
println!("| export: same view, glass hidden | {without:.1} |");
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Glass renders the same however a render is cut: a band or a canvas patch whose edge runs
|
||||
//! through a pane shows exactly the pixels of the whole render.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
|
||||
use figcraft_geom::Affine;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use crate::{Options, View, render_view_single};
|
||||
|
||||
/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page, pane).
|
||||
fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new("glass");
|
||||
let p = d.first_page().unwrap();
|
||||
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
|
||||
for i in 0..24u32 {
|
||||
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
|
||||
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
|
||||
}
|
||||
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.transform = Affine::translate((300.0, 220.0));
|
||||
n.corners.radii = [radius; 4];
|
||||
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
(d, p, pane)
|
||||
}
|
||||
|
||||
/// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel
|
||||
/// difference and how many bytes differ.
|
||||
fn diff_rows(full: &Pixmap, y0: u32, part: &Pixmap) -> (u8, usize) {
|
||||
let row = full.width() as usize * 4;
|
||||
let a = &full.data()[y0 as usize * row..][..part.data().len()];
|
||||
a.iter().zip(part.data()).fold((0, 0), |(m, n), (x, y)| (m.max(x.abs_diff(*y)), n + usize::from(x != y)))
|
||||
}
|
||||
|
||||
/// A strong, wide light and nothing that blurs or bends: the light reaches furthest in, and the
|
||||
/// backdrop effects read nothing past the layer, so a cut through the pane gets no margin.
|
||||
fn wide_light() -> Glass {
|
||||
Glass { splay: 1.0, frost: 0.0, refraction: 0.0, light_intensity: 1.0, ..Glass::default() }
|
||||
}
|
||||
|
||||
/// The largest channel difference and bytes differing between the whole render of `d` and renders
|
||||
/// cut 50 to 80 px inside the pane's top edge (y = 440 px), as bands and canvas patches cut it:
|
||||
/// rendered with the backdrop reach as a margin above and below, keeping 400 rows from the cut.
|
||||
fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> {
|
||||
let opts = Options::default();
|
||||
let full = render_view_single(d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 2400, height: 1600 }, &opts).unwrap();
|
||||
let margin = crate::backdrop_reach(crate::max_backdrop_blur(d), 2.0).ceil() as u32 + 1;
|
||||
[490u32, 505, 520]
|
||||
.into_iter()
|
||||
.map(|cut| {
|
||||
let top = cut - margin;
|
||||
let v = View { origin: (0.0, f64::from(top) / 2.0), scale: 2.0, width: 2400, height: 400 + 2 * margin };
|
||||
let part = render_view_single(d, p, &v, &opts).unwrap();
|
||||
let kept = part.clone_rect(tiny_skia::IntRect::from_xywh(0, margin as i32, 2400, 400).unwrap()).unwrap();
|
||||
(cut, diff_rows(&full, cut, &kept))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pane deeper than the light may reach, over black and lit from the left: across its middle row
|
||||
/// each pixel is the light alone. It fades out smoothly where it stops, with no ring.
|
||||
#[test]
|
||||
fn the_light_fades_out_where_it_stops() {
|
||||
let mut d = Document::new("glass");
|
||||
let p = d.first_page().unwrap();
|
||||
let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap();
|
||||
d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap();
|
||||
let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.fills.clear();
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap();
|
||||
let row = &full.data()[700 * 1400 * 4..][..1400 * 4];
|
||||
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect();
|
||||
let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
|
||||
assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels");
|
||||
assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]);
|
||||
}
|
||||
|
||||
/// Digests of glass renders over a spread of settings, scales and corners: compare two builds'
|
||||
/// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "prints digests to compare builds"]
|
||||
fn glass_digests() {
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
let looks = [
|
||||
Glass::default(),
|
||||
wide_light(),
|
||||
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
|
||||
Glass { refraction: 1.0, dispersion: 1.0, frost: 10.0, depth: 60.0, light_intensity: 0.0, ..Glass::default() },
|
||||
Glass { depth: 1.0, splay: 0.3, light_angle: 120.0, ..Glass::default() },
|
||||
];
|
||||
let mut all = DefaultHasher::new();
|
||||
for (i, g) in looks.into_iter().enumerate() {
|
||||
for radius in [0.0, 40.0] {
|
||||
let (d, p, _) = pane_over_stripes(g.clone(), radius);
|
||||
for scale in [1.0, 1.37, 2.0] {
|
||||
let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 };
|
||||
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
|
||||
let mut h = DefaultHasher::new();
|
||||
pix.data().hash(&mut h);
|
||||
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
|
||||
pix.data().hash(&mut all);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("all: {:016x}", all.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_through_the_pane_matches_the_whole_render() {
|
||||
// The last look is deep enough that the light is held to `MAX_LIGHT_PX`.
|
||||
let looks = [
|
||||
wide_light(),
|
||||
Glass::default(),
|
||||
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
|
||||
Glass { depth: 300.0, ..wide_light() },
|
||||
];
|
||||
// Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a
|
||||
// cut, and that is the fill, not the glass.
|
||||
for radius in [0.0, 40.0] {
|
||||
for g in &looks {
|
||||
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
|
||||
if radius > 0.0 {
|
||||
d.get_mut(pane).unwrap().fills.clear();
|
||||
}
|
||||
for (cut, diff) in cut_diffs(&d, p) {
|
||||
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,10 @@ mod effects_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod gif_tests;
|
||||
#[cfg(test)]
|
||||
mod glass_bench_tests;
|
||||
#[cfg(test)]
|
||||
mod glass_cut_tests;
|
||||
#[cfg(test)]
|
||||
mod grain_tests;
|
||||
#[cfg(test)]
|
||||
mod group_effects_tests;
|
||||
|
||||
@@ -330,3 +330,28 @@ fn inner_shadow_reaches_every_edge() {
|
||||
assert!(px(&pix, 11, 25)[0] < 235, "{:?}", px(&pix, 11, 25));
|
||||
assert!(px(&pix, 35, 30)[0] > 245);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_clip_frame_matches_same_rect_fill_on_fractional_edges() {
|
||||
let white = Options { background: Some(Color::WHITE), ..Default::default() };
|
||||
for (x, y, w, h) in [(0.5, 0.5, 10.0, 10.0), (0.0, 0.0, 10.5, 10.5), (0.0, 0.0, 10.0, 10.0)] {
|
||||
let mut clipped = figcraft_doc::Document::new("clipped");
|
||||
let p = clipped.first_page().unwrap();
|
||||
let f = clipped.create(p, NodeKind::Frame, "F", x, y, w, h).unwrap();
|
||||
clipped.get_mut(f).unwrap().fills.clear();
|
||||
clipped.get_mut(f).unwrap().clip = true;
|
||||
let c = clipped.create(f, NodeKind::Rectangle, "C", -5.0, -5.0, w + 10.0, h + 10.0).unwrap();
|
||||
clipped.get_mut(c).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
|
||||
let mut direct = figcraft_doc::Document::new("direct");
|
||||
let dp = direct.first_page().unwrap();
|
||||
let r = direct.create(dp, NodeKind::Rectangle, "R", x, y, w, h).unwrap();
|
||||
direct.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
|
||||
let a = render_view(&clipped, p, &view(12, 12), &white).unwrap();
|
||||
let e = render_view(&direct, dp, &view(12, 12), &white).unwrap();
|
||||
for (px_x, px_y) in [(0, 0), (0, 5), (5, 0), (5, 5), (10, 5), (5, 10), (10, 10), (11, 11)] {
|
||||
assert_eq!(px(&a, px_x, px_y), px(&e, px_x, px_y), "frame {x},{y} {w}x{h} at ({px_x},{px_y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,8 +431,19 @@ impl W<'_> {
|
||||
};
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
|
||||
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
|
||||
let run_paths = if live_text.is_none() { text_run_paths(n) } else { None };
|
||||
if let Some(t) = &live_text {
|
||||
self.body.push_str(t);
|
||||
} else if let Some(runs) = &run_paths {
|
||||
// Character ranges with their own fills: one path per style, like the canvas.
|
||||
for (path, fills) in runs {
|
||||
for p in fills {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}/>", matrix(xf), path_d(path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some((path, rule)) = &outline
|
||||
&& closed
|
||||
{
|
||||
@@ -559,6 +570,36 @@ impl W<'_> {
|
||||
if let Some((layout, dy)) = figcraft_text::layout_node(n) {
|
||||
gxf = xf * Affine::translate((0.0, dy));
|
||||
for line in &layout.lines {
|
||||
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
||||
let run_fills = !line.rtl
|
||||
&& !glyphs.iter().any(|g| g.rtl)
|
||||
&& glyphs.iter().any(|g| layout.styles.get(g.style).is_some_and(|s| s.fills.is_some()));
|
||||
if run_fills {
|
||||
// One tspan per run of glyphs in the same style, each in its own fill.
|
||||
let mut i = 0;
|
||||
let mut start = line.char_start;
|
||||
while i < glyphs.len() {
|
||||
let style = glyphs[i].style;
|
||||
let mut j = i + 1;
|
||||
while j < glyphs.len() && glyphs[j].style == style {
|
||||
j += 1;
|
||||
}
|
||||
let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
|
||||
let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
|
||||
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
|
||||
let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
|
||||
Some(fills) => {
|
||||
let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
|
||||
format!(" {a}")
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\"{own}>{}</tspan>", num(glyphs[i].x), num(glyphs[i].y), esc(&text));
|
||||
start = end;
|
||||
i = j;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
|
||||
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
|
||||
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
|
||||
@@ -979,6 +1020,33 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu
|
||||
))
|
||||
}
|
||||
|
||||
/// Text layer `n`'s glyph outlines grouped by style, each with the fills it paints in (its own, else
|
||||
/// the layer's), in layer space. `None` unless some character range has fills of its own.
|
||||
fn text_run_paths(n: &Node) -> Option<Vec<(BezPath, Vec<Paint>)>> {
|
||||
let NodeKind::Text(_) = &n.kind else { return None };
|
||||
let (layout, dy) = figcraft_text::layout_node(n)?;
|
||||
if !layout.styles.iter().any(|s| s.fills.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let mut by_style: std::collections::BTreeMap<usize, BezPath> = std::collections::BTreeMap::new();
|
||||
for g in &layout.glyphs {
|
||||
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
|
||||
}
|
||||
for d in &layout.decorations {
|
||||
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
|
||||
}
|
||||
Some(
|
||||
by_style
|
||||
.into_iter()
|
||||
.map(|(k, mut path)| {
|
||||
path.apply_affine(Affine::translate((0.0, dy)));
|
||||
let fills = layout.styles.get(k).and_then(|s| s.fills.clone()).unwrap_or_else(|| n.fills.clone());
|
||||
(path, fills)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -82,3 +82,36 @@ fn nested_frame_paint_is_clipped_in_page_space() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "ab" in black with a green second character, as outlines and as live text.
|
||||
fn two_colour_text() -> (Document, figcraft_doc::NodeId) {
|
||||
use figcraft_doc::{StyleRun, TextData};
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut d = Document::new("t");
|
||||
let page = d.first_page().unwrap();
|
||||
let mut text = TextData::new("ab");
|
||||
text.style.font_size = 24.0;
|
||||
let mut style = text.style.clone();
|
||||
style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
|
||||
text.runs.push(StyleRun { start: 1, end: 2, style });
|
||||
text.normalize();
|
||||
let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 100.0, 40.0).unwrap();
|
||||
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
(d, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_run_fill_survives_outlined_export() {
|
||||
let (d, id) = two_colour_text();
|
||||
let svg = export_svg(&d, &[id], &SvgOptions { outline_text: true, ..SvgOptions::default() }).unwrap();
|
||||
assert!(svg.contains("#000000") && svg.contains("#00FF00"), "{svg}");
|
||||
assert_eq!(svg.matches("<path ").count(), 2, "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_run_fill_survives_live_text_export() {
|
||||
let (d, id) = two_colour_text();
|
||||
let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap();
|
||||
assert!(svg.contains("#000000"), "{svg}");
|
||||
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b</tspan>") && svg.contains(">a</tspan>"), "{svg}");
|
||||
}
|
||||
@@ -237,6 +237,34 @@ fn fallback_fonts_dont_move_the_baseline() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hangul_comes_from_a_korean_font_when_one_is_installed() {
|
||||
// The default (Latin) face has no Hangul. With a Korean system font (Apple SD Gothic Neo on
|
||||
// macOS, Malgun Gothic on Windows, Noto Sans CJK KR on Linux) the fallback draws it instead
|
||||
// of the missing-glyph box, at the run's weight. Skips on a machine without one.
|
||||
let Some(f) = face() else { return };
|
||||
if f.has_char('\u{D55C}') {
|
||||
return;
|
||||
}
|
||||
let korean = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR"];
|
||||
if !korean.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char('\u{D55C}'))) {
|
||||
return;
|
||||
}
|
||||
for style in ["Regular", "Bold"] {
|
||||
let mut t = text("Ab \u{D55C}\u{AD6D}\u{C5B4}", TextAutoResize::WidthAndHeight);
|
||||
t.style.font_style = style.into();
|
||||
let l = layout(&t, None, None);
|
||||
let hangul: Vec<_> = l.glyphs.iter().filter(|g| g.char_start >= 3).collect();
|
||||
assert!(!hangul.is_empty());
|
||||
for g in &hangul {
|
||||
assert!(g.face.has_char('\u{D55C}'), "{style}: drawn by {} {}, which has no Hangul", g.face.family, g.face.style);
|
||||
}
|
||||
let used = &hangul[0].face;
|
||||
let nearest = find_face(&used.family, style).map_or(used.weight, |n| n.weight);
|
||||
assert_eq!(used.weight, nearest, "{style}: the fallback face is the one nearest the run's style");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_opportunities() {
|
||||
// (before, after, may break between, derivation)
|
||||
|
||||
+35
-17
@@ -405,6 +405,28 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
(family, style)
|
||||
}
|
||||
|
||||
/// Families tried, in order, for characters a text's own font lacks.
|
||||
const FALLBACK_FAMILIES: [&str; 18] = [
|
||||
"Inter",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Segoe UI",
|
||||
"DejaVu Sans",
|
||||
"Noto Sans",
|
||||
"BIZ UDPGothic",
|
||||
"Hiragino Sans",
|
||||
"PingFang SC",
|
||||
"Noto Sans CJK SC",
|
||||
"Microsoft YaHei",
|
||||
"Apple SD Gothic Neo",
|
||||
"Malgun Gothic",
|
||||
"Noto Sans CJK KR",
|
||||
"Noto Sans KR",
|
||||
"Noto Sans Arabic",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
];
|
||||
|
||||
/// A system font file entry not yet loaded.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Pending {
|
||||
@@ -614,24 +636,15 @@ impl FontDb {
|
||||
|
||||
/// Faces to try for characters the primary face lacks.
|
||||
pub fn fallbacks(&mut self) -> Vec<Arc<Face>> {
|
||||
self.fallbacks_for("Regular")
|
||||
}
|
||||
|
||||
/// [`fallbacks`](Self::fallbacks), each family's face nearest `style`, so the characters a
|
||||
/// bold run takes from a fallback font (Hangul in Inter Bold) come out bold too.
|
||||
pub fn fallbacks_for(&mut self, style: &str) -> Vec<Arc<Face>> {
|
||||
let mut out = Vec::new();
|
||||
for fam in [
|
||||
"Inter",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Segoe UI",
|
||||
"DejaVu Sans",
|
||||
"Noto Sans",
|
||||
"BIZ UDPGothic",
|
||||
"Hiragino Sans",
|
||||
"PingFang SC",
|
||||
"Noto Sans CJK SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans Arabic",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
] {
|
||||
if let Some(f) = self.find(fam, "Regular") {
|
||||
for fam in FALLBACK_FAMILIES {
|
||||
if let Some(f) = self.find(fam, style) {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
@@ -734,6 +747,11 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
||||
scan_dir(&path, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
// Font-like names can also belong to pipes or devices; opening a pipe can block.
|
||||
// `meta` follows symlinks, so links to regular font files remain supported.
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
|
||||
continue;
|
||||
|
||||
@@ -56,7 +56,9 @@ fn the_scan_reads_names_like_a_whole_file_read() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| ["ttf", "otf", "ttc", "otc"].contains(&x.to_ascii_lowercase().to_str().unwrap_or(""))) {
|
||||
} else if p.is_file()
|
||||
&& p.extension().is_some_and(|x| ["ttf", "otf", "ttc", "otc"].contains(&x.to_ascii_lowercase().to_str().unwrap_or("")))
|
||||
{
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +70,90 @@ fn the_scan_reads_names_like_a_whole_file_read() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_scan_skips_named_pipes() {
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const INPUT: &str = "FIGCRAFT_TEST_PIPE_FONT_DIR";
|
||||
if let Some(dir) = std::env::var_os(INPUT) {
|
||||
let mut pending = BTreeMap::new();
|
||||
scan_dir(std::path::Path::new(&dir), 0, &mut pending);
|
||||
assert!(pending.is_empty());
|
||||
std::fs::write(std::path::Path::new(&dir).join("complete"), b"scanned").unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("figcraft-font-pipe-{}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let dir = TempDir(path);
|
||||
// POSIX mkfifo is only a test fixture helper, never a runtime font dependency.
|
||||
let status = match Command::new("mkfifo").arg(dir.0.join("pipe.ttf")).status() {
|
||||
Ok(status) => status,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("skipping named-pipe fixture: POSIX mkfifo is unavailable");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("cannot create named-pipe fixture: {error}"),
|
||||
};
|
||||
assert!(status.success(), "mkfifo failed");
|
||||
std::os::unix::fs::symlink("pipe.ttf", dir.0.join("linked.otf")).unwrap();
|
||||
std::os::unix::fs::symlink("missing.ttf", dir.0.join("broken.ttf")).unwrap();
|
||||
|
||||
// A regression must fail without leaving a blocked test thread behind. Run only this
|
||||
// test in a child, then kill and reap it if opening the pipe blocks the scanner.
|
||||
struct Child(std::process::Child);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let module = module_path!().split_once("::").unwrap().1;
|
||||
let mut child = Child(
|
||||
Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", &format!("{module}::the_scan_skips_named_pipes"), "--nocapture"])
|
||||
.env(INPUT, &dir.0)
|
||||
.stdout(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(status) = child.0.try_wait().unwrap() {
|
||||
assert!(status.success(), "font-scan child failed: {status}");
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "font scan blocked on a named pipe");
|
||||
// std::process has no portable timed wait; a bounded poll keeps the regression finite.
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
// An exact-filter typo otherwise makes libtest report success after running zero tests.
|
||||
assert_eq!(std::fs::read(dir.0.join("complete")).unwrap(), b"scanned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_faces_follow_the_runs_style() {
|
||||
// A bold run's missing characters come from each fallback family's face nearest Bold, not its
|
||||
// Regular, so Hangul or kana in a bold Latin run come out bold too. Same families, same order.
|
||||
scan_system_fonts();
|
||||
let mut w = db().write().unwrap();
|
||||
let regular = w.fallbacks();
|
||||
let bold = w.fallbacks_for("Bold");
|
||||
let families = |v: &[Arc<Face>]| v.iter().map(|f| f.family.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(families(®ular), families(&bold));
|
||||
for (r, b) in regular.iter().zip(&bold) {
|
||||
assert!(b.weight >= r.weight, "{}: {} is lighter than {}", b.family, b.style, r.style);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn user_font_dirs_follow_xdg_data_home() {
|
||||
|
||||
@@ -325,8 +325,10 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut fallbacks = dbw.fallbacks();
|
||||
let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
|
||||
let mut style_fallbacks: Vec<Vec<Arc<Face>>> = Vec::with_capacity(styles.len());
|
||||
for s in &styles {
|
||||
faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
|
||||
style_fallbacks.push(dbw.fallbacks_for(&s.font_style));
|
||||
}
|
||||
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
|
||||
// for a text with emoji, or with characters nothing above covers.
|
||||
@@ -371,6 +373,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
if c == '\n' || primary.has_char(c) {
|
||||
return primary.clone();
|
||||
}
|
||||
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
|
||||
fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
|
||||
};
|
||||
let first = chars.get(seg_start).copied().unwrap_or(' ');
|
||||
|
||||
@@ -7,7 +7,7 @@ status = "draft — needs native review"
|
||||
[ui]
|
||||
"% of all commits" = "% všech commitů"
|
||||
"(no longer published)" = "(již nepublikováno)"
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", other = "(Varování: {n}; některé položky byly přeskočeny)" }
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", few = "({n} varování: některé položky byly přeskočeny)", other = "({n} varování: některé položky byly přeskočeny)" }
|
||||
"+ Create variable" = "+ Vytvořit proměnnou"
|
||||
".fig (experimental)" = ".fig (experimentální)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
@@ -455,11 +455,11 @@ status = "draft — needs native review"
|
||||
"Export settings" = "Nastavení exportu"
|
||||
"Export variables" = "Exportovat proměnné"
|
||||
"Export {name}" = "Exportovat {name}"
|
||||
"Export {n} layers" = { one = "Exportovat {n} vrstvu", other = "Exportovat vrstvy: {n}" }
|
||||
"Export {n} layers" = { one = "Exportovat {n} vrstvu", few = "Exportovat {n} vrstvy", other = "Exportovat {n} vrstev" }
|
||||
"Exported {file}" = "Exportován soubor {file}"
|
||||
"Exported {name}" = "Exportováno: {name}"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", other = "Exportováno: {name}; vynechané druhy obsahu: {n}" }
|
||||
"Exported {n} files" = { one = "Exportován {n} soubor", other = "Exportované soubory: {n}" }
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", few = "Exportováno: {name}; vynechány {n} druhy obsahu", other = "Exportováno: {name}; vynecháno {n} druhů obsahu" }
|
||||
"Exported {n} files" = { one = "Exportován {n} soubor", few = "Exportovány {n} soubory", other = "Exportováno {n} souborů" }
|
||||
"Export…" = "Exportovat…"
|
||||
"Expose properties to instances" = "Zpřístupnit vlastnosti instancím"
|
||||
"Exposure" = "Expozice"
|
||||
@@ -477,7 +477,7 @@ status = "draft — needs native review"
|
||||
"File not found" = "Soubor nenalezen"
|
||||
"File published" = "Soubor publikován"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Soubory nemají koncové šifrování: provozovatel serveru je může číst."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} d, se smažou." }
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", few = "Soubory, které nikdo neotevře déle než {n} dny, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} dní, se smažou." }
|
||||
"Fill" = "Výplň"
|
||||
"Fill (fr)" = "Vyplnit (fr)"
|
||||
"Fill container" = "Vyplnit kontejner"
|
||||
@@ -601,7 +601,7 @@ status = "draft — needs native review"
|
||||
"Import plugin from manifest…" = "Importovat plugin z manifestu…"
|
||||
"Import variables" = "Importovat proměnné"
|
||||
"Imported {name}" = "Importováno: {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", other = "Nově importované proměnné: {n}, aktualizované: {updated}" }
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", few = "Importovány {n} nové proměnné, aktualizováno: {updated}", other = "Importováno {n} nových proměnných, aktualizováno: {updated}" }
|
||||
"Import…" = "Importovat…"
|
||||
"In an instance, this comes from the main component" = "V instanci se toto přebírá z hlavní komponenty"
|
||||
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "V prohlížeči se můžete připojit k relacím hostovaným desktopovou aplikací a k souborům na serveru FigCraft. Stránka otevřená přes https může přistupovat k jiným počítačům pouze přes odkazy wss:// (server za TLS)."
|
||||
@@ -1013,7 +1013,7 @@ status = "draft — needs native review"
|
||||
"Recent" = "Nedávné"
|
||||
"Recent files" = "Nedávné soubory"
|
||||
"Recents" = "Nedávné"
|
||||
"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", other = "Po neočekávaném ukončení aplikace obnoveny neuložené soubory (počet: {n}): uložte je pro zachování" }
|
||||
"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", few = "Po neočekávaném ukončení aplikace obnoveny {n} neuložené soubory: uložte je pro zachování", other = "Po neočekávaném ukončení aplikace obnoveno {n} neuložených souborů: uložte je pro zachování" }
|
||||
"Rectangle" = "Obdélník"
|
||||
"Rectangles" = "Obdélníky"
|
||||
"Red" = "Červená"
|
||||
@@ -1287,8 +1287,8 @@ status = "draft — needs native review"
|
||||
"Swap to {name}" = "Vyměnit za {name}"
|
||||
"Swap width and height" = "Prohodit šířku a výšku"
|
||||
"Swapped instance" = "Instance vyměněna"
|
||||
"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", other = "Vyměněné zdroje: {n}" }
|
||||
"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněné zdroje: {n}; bez shody: {missed}, nadále používají původní knihovnu" }
|
||||
"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", few = "Vyměněny {n} zdroje", other = "Vyměněno {n} zdrojů" }
|
||||
"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", few = "Vyměněny {n} zdroje; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněno {n} zdrojů; bez shody: {missed}, nadále používají původní knihovnu" }
|
||||
"Sweep" = "Rozsah"
|
||||
"System" = "Systém"
|
||||
"Tab / Shift+Tab" = "Tab / Shift+Tab"
|
||||
@@ -1479,7 +1479,7 @@ status = "draft — needs native review"
|
||||
"Zoom to selection" = "Přiblížit na výběr"
|
||||
"Zoom tool: click to zoom in, drag an area" = "Nástroj přiblížení: kliknutím přiblížíte, tažením vyberete oblast"
|
||||
"Zoom tool: zoom out" = "Nástroj přiblížení: oddálit"
|
||||
"and {n} more" = { one = "a {n} další", other = "a další (počet: {n})" }
|
||||
"and {n} more" = { one = "a {n} další", few = "a {n} další", other = "a {n} dalších" }
|
||||
"bleed marks" = "značky spadávky"
|
||||
"by {author}" = "autor: {author}"
|
||||
"call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními"
|
||||
@@ -1531,35 +1531,35 @@ status = "draft — needs native review"
|
||||
"{name}, in a live session" = "{name}, v živé relaci"
|
||||
"{name}, online, open only to you" = "{name}, online, přístupný pouze vám"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno"
|
||||
"{n} added" = { one = "Přidáno: {n}", other = "Přidáno: {n}" }
|
||||
"{n} autosave versions" = { one = "{n} automaticky uložená verze", other = "Automaticky uložené verze: {n}" }
|
||||
"{n} changed" = { one = "Změněno: {n}", other = "Změněno: {n}" }
|
||||
"{n} columns" = { one = "{n} sloupec", other = "Sloupce: {n}" }
|
||||
"{n} components" = { one = "{n} komponenta", other = "Komponenty: {n}" }
|
||||
"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", other = "Komponenty a styly s novější verzí: {n}" }
|
||||
"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", other = "Konflikty k vyřešení: {n}" }
|
||||
"{n} days ago" = { one = "Před {n} dnem", other = "Před {n} dny" }
|
||||
"{n} files" = { one = "{n} soubor", other = "Soubory: {n}" }
|
||||
"{n} hours ago" = { one = "Před {n} hodinou", other = "Před {n} hodinami" }
|
||||
"{n} instances" = { one = "{n} instance", other = "Instance: {n}" }
|
||||
"{n} layers" = { one = "{n} vrstva", other = "Vrstvy: {n}" }
|
||||
"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", other = "Vrstvy jako jeden soubor: {n}" }
|
||||
"{n} layers selected" = { one = "Vybrána {n} vrstva", other = "Vybrané vrstvy: {n}" }
|
||||
"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", other = "Vrstvy: {n} · {parts}" }
|
||||
"{n} matches" = { one = "{n} shoda", other = "Shody: {n}" }
|
||||
"{n} minutes ago" = { one = "Před {n} minutou", other = "Před {n} minutami" }
|
||||
"{n} months ago" = { one = "Před {n} měsícem", other = "Před {n} měsíci" }
|
||||
"{n} added" = { one = "Přidáno: {n}", few = "Přidáno: {n}", other = "Přidáno: {n}" }
|
||||
"{n} autosave versions" = { one = "{n} automaticky uložená verze", few = "{n} automaticky uložené verze", other = "{n} automaticky uložených verzí" }
|
||||
"{n} changed" = { one = "Změněno: {n}", few = "Změněno: {n}", other = "Změněno: {n}" }
|
||||
"{n} columns" = { one = "{n} sloupec", few = "{n} sloupce", other = "{n} sloupců" }
|
||||
"{n} components" = { one = "{n} komponenta", few = "{n} komponenty", other = "{n} komponent" }
|
||||
"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", few = "{n} komponenty nebo styly mají novější verzi", other = "{n} komponent nebo stylů má novější verzi" }
|
||||
"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", few = "{n} konflikty k vyřešení", other = "{n} konfliktů k vyřešení" }
|
||||
"{n} days ago" = { one = "Před {n} dnem", few = "Před {n} dny", other = "Před {n} dny" }
|
||||
"{n} files" = { one = "{n} soubor", few = "{n} soubory", other = "{n} souborů" }
|
||||
"{n} hours ago" = { one = "Před {n} hodinou", few = "Před {n} hodinami", other = "Před {n} hodinami" }
|
||||
"{n} instances" = { one = "{n} instance", few = "{n} instance", other = "{n} instancí" }
|
||||
"{n} layers" = { one = "{n} vrstva", few = "{n} vrstvy", other = "{n} vrstev" }
|
||||
"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", few = "{n} vrstvy jako jeden soubor", other = "{n} vrstev jako jeden soubor" }
|
||||
"{n} layers selected" = { one = "Vybrána {n} vrstva", few = "Vybrány {n} vrstvy", other = "Vybráno {n} vrstev" }
|
||||
"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", few = "{n} vrstvy · {parts}", other = "{n} vrstev · {parts}" }
|
||||
"{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" }
|
||||
"{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" }
|
||||
"{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" }
|
||||
"{n} of {total}" = "{n} z {total}"
|
||||
"{n} people joined" = { one = "Připojena {n} osoba", other = "Připojené osoby: {n}" }
|
||||
"{n} preferred components" = { one = "{n} preferovaná komponenta", other = "Preferované komponenty: {n}" }
|
||||
"{n} ready" = { one = "Připraveno: {n}", other = "Připraveno: {n}" }
|
||||
"{n} removed" = { one = "Odebráno: {n}", other = "Odebráno: {n}" }
|
||||
"{n} replies" = { one = "{n} odpověď", other = "Odpovědi: {n}" }
|
||||
"{n} rows" = { one = "{n} řádek", other = "Řádky: {n}" }
|
||||
"{n} styles" = { one = "{n} styl", other = "Styly: {n}" }
|
||||
"{n} variables" = { one = "{n} proměnná", other = "Proměnné: {n}" }
|
||||
"{n} weeks ago" = { one = "Před {n} týdnem", other = "Před {n} týdny" }
|
||||
"{n} years ago" = { one = "Před {n} rokem", other = "Před {n} lety" }
|
||||
"{n} people joined" = { one = "Připojila se {n} osoba", few = "Připojily se {n} osoby", other = "Připojilo se {n} osob" }
|
||||
"{n} preferred components" = { one = "{n} preferovaná komponenta", few = "{n} preferované komponenty", other = "{n} preferovaných komponent" }
|
||||
"{n} ready" = { one = "Připraveno: {n}", few = "Připraveno: {n}", other = "Připraveno: {n}" }
|
||||
"{n} removed" = { one = "Odebráno: {n}", few = "Odebráno: {n}", other = "Odebráno: {n}" }
|
||||
"{n} replies" = { one = "{n} odpověď", few = "{n} odpovědi", other = "{n} odpovědí" }
|
||||
"{n} rows" = { one = "{n} řádek", few = "{n} řádky", other = "{n} řádků" }
|
||||
"{n} styles" = { one = "{n} styl", few = "{n} styly", other = "{n} stylů" }
|
||||
"{n} variables" = { one = "{n} proměnná", few = "{n} proměnné", other = "{n} proměnných" }
|
||||
"{n} weeks ago" = { one = "Před {n} týdnem", few = "Před {n} týdny", other = "Před {n} týdny" }
|
||||
"{n} years ago" = { one = "Před {n} rokem", few = "Před {n} lety", other = "Před {n} lety" }
|
||||
"{n}d" = "{n} d"
|
||||
"{n}h" = "{n} h"
|
||||
"{n}m" = "{n} min"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -813,6 +813,11 @@ pub fn button(resp: &Response, tip: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A button named by `label` as it is: no key is split off (a file name may hold two spaces).
|
||||
pub fn named_button(resp: &Response, label: &str) {
|
||||
node(resp, label, |n| n.set_role(Role::Button));
|
||||
}
|
||||
|
||||
/// A link, named by its text.
|
||||
pub fn link(resp: &Response, label: &str) {
|
||||
node(resp, label, |n| n.set_role(Role::Link));
|
||||
|
||||
@@ -314,6 +314,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
|
||||
.hint_text(tr("Search assets"))
|
||||
.desired_width(te_rect.width()),
|
||||
);
|
||||
crate::widgets::select_all_on_focus(ui, &r, &filter);
|
||||
if r.has_focus() {
|
||||
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
|
||||
@@ -183,9 +183,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
}
|
||||
}
|
||||
let own_cursor = input(app, ui, &resp);
|
||||
crate::guides::end_released(&ctx);
|
||||
crate::live::track_pointer(app, &ctx);
|
||||
app.sync_preedit();
|
||||
request_render(app, worker, ppp);
|
||||
// Retained rendering snaps the view outward and adds one pixel on each axis.
|
||||
let max_texture_side = (ctx.input(|i| i.max_texture_side).min(16_384) as u32).saturating_sub(1).max(1);
|
||||
request_render(app, worker, ppp, max_texture_side);
|
||||
// Every finished render is shown, even when a newer one is already on its way: renders finish
|
||||
// in order, and an edit every frame (a drag) would otherwise never show until it stopped.
|
||||
let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok();
|
||||
@@ -321,11 +324,11 @@ fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool
|
||||
o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps
|
||||
}
|
||||
|
||||
fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_side: u32) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let rect = app.canvas.rect;
|
||||
let (mut ox, mut oy) = ds.view.origin();
|
||||
let (scale, w, h) = if let Some(k) = ds.view.pixel_preview_scale() {
|
||||
let (scale, visible_w, visible_h) = if let Some(k) = ds.view.pixel_preview_scale() {
|
||||
// Pixel preview: render `k` pixels per page unit from a whole-pixel origin, shown magnified
|
||||
// without smoothing.
|
||||
(ox, oy) = ((ox * k).floor() / k, (oy * k).floor() / k);
|
||||
@@ -339,12 +342,20 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] };
|
||||
let proof = ds.view.proof_srgb && !doc.color_profile.is_srgb();
|
||||
let outline_extras = figcraft_render::Outline { hidden: ds.view.outline_hidden, bounds: ds.view.outline_bounds, light: false };
|
||||
// Render beyond the visible viewport. During a pan the previous texture then still covers
|
||||
// the screen while the worker renders newly exposed content. Request a new buffer before the
|
||||
// visible area reaches its edge, rather than exposing an empty strip each pointer move.
|
||||
let margin = overscan_margin(visible_w, visible_h, max_texture_side);
|
||||
let origin = (ox, oy);
|
||||
let visible = (visible_w, visible_h);
|
||||
let requested = (ox - f64::from(margin) / scale, oy - f64::from(margin) / scale);
|
||||
let size = (visible_w.saturating_add(margin * 2).min(max_texture_side), visible_h.saturating_add(margin * 2).min(max_texture_side));
|
||||
let key = JobKey {
|
||||
doc: Arc::as_ptr(&doc) as usize,
|
||||
page: ds.page,
|
||||
origin: ((ox * 1000.0) as i64, (oy * 1000.0) as i64),
|
||||
origin: ((requested.0 * 1000.0) as i64, (requested.1 * 1000.0) as i64),
|
||||
scale: (scale * 10000.0) as i64,
|
||||
size: (w, h),
|
||||
size,
|
||||
outline: ds.view.outlines,
|
||||
outline_extras,
|
||||
outlined: outlined.clone(),
|
||||
@@ -353,13 +364,21 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
fonts: figcraft_text::layout_generation(),
|
||||
proof,
|
||||
};
|
||||
if let Some(previous) = &app.canvas.submitted {
|
||||
let previous_origin = (previous.origin.0 as f64 / 1000.0, previous.origin.1 as f64 / 1000.0);
|
||||
let same_content = JobKey { origin: previous.origin, size: previous.size, ..key.clone() } == *previous;
|
||||
let covers = cached_render_covers(previous_origin, previous.size, origin, visible, scale);
|
||||
if same_content && covers {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if app.canvas.submitted.as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
let job = Job {
|
||||
doc,
|
||||
page: ds.page,
|
||||
view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) },
|
||||
view: figcraft_render::View { origin: requested, scale, width: size.0, height: size.1 },
|
||||
outline: ds.view.outlines,
|
||||
outline_extras,
|
||||
outlined,
|
||||
@@ -375,6 +394,53 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
app.canvas.submitted = Some(key);
|
||||
}
|
||||
|
||||
/// Leave a 96-pixel reserve so the next render starts before a pan reaches a cached edge.
|
||||
fn cached_render_covers(buffer_origin: (f64, f64), buffer_size: (u32, u32), view_origin: (f64, f64), view_size: (u32, u32), scale: f64) -> bool {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let guard_x = 96.0_f64.min(f64::from(buffer_size.0.saturating_sub(view_size.0)) / 4.0) / scale;
|
||||
let guard_y = 96.0_f64.min(f64::from(buffer_size.1.saturating_sub(view_size.1)) / 4.0) / scale;
|
||||
view_origin.0 >= buffer_origin.0 + guard_x
|
||||
&& view_origin.1 >= buffer_origin.1 + guard_y
|
||||
&& view_origin.0 + f64::from(view_size.0) / scale <= buffer_origin.0 + f64::from(buffer_size.0) / scale - guard_x
|
||||
&& view_origin.1 + f64::from(view_size.1) / scale <= buffer_origin.1 + f64::from(buffer_size.1) / scale - guard_y
|
||||
}
|
||||
|
||||
/// Bound the extra rendered area to about 1.6 times the visible area. A fixed large margin can
|
||||
/// multiply the pixel count several-fold on a narrow or small viewport.
|
||||
fn overscan_margin(width: u32, height: u32, max_texture_side: u32) -> u32 {
|
||||
width.min(height).saturating_div(8).min(128).min(max_texture_side.saturating_sub(width.max(height)) / 2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn pan_uses_cached_pixels_until_the_refill_boundary() {
|
||||
let margin = overscan_margin(1000, 800, 16_383);
|
||||
assert_eq!(margin, 100);
|
||||
let buffer = (-f64::from(margin), -f64::from(margin));
|
||||
let size = (1000 + 2 * margin, 800 + 2 * margin);
|
||||
assert!(cached_render_covers(buffer, size, (0.0, 0.0), (1000, 800), 1.0));
|
||||
assert!(cached_render_covers(buffer, size, (50.0, 0.0), (1000, 800), 1.0));
|
||||
assert!(!cached_render_covers(buffer, size, (80.0, 0.0), (1000, 800), 1.0));
|
||||
assert!(!cached_render_covers(buffer, size, (-80.0, 0.0), (1000, 800), 1.0));
|
||||
assert!(cached_render_covers((-24.0, -24.0), (1048, 848), (10.0, 0.0), (1000, 800), 1.0));
|
||||
assert!(!cached_render_covers((-24.0, -24.0), (1048, 848), (19.0, 0.0), (1000, 800), 1.0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn overscan_stays_within_texture_and_pixel_budget() {
|
||||
for (width, height, limit) in [(1000, 800, 16_383), (390, 844, 16_383), (2560, 1440, 16_383), (1000, 800, 1060)] {
|
||||
let margin = overscan_margin(width, height, limit);
|
||||
assert!(width + 2 * margin <= limit || width > limit);
|
||||
assert!(height + 2 * margin <= limit || height > limit);
|
||||
let rendered = u64::from(width + 2 * margin) * u64::from(height + 2 * margin);
|
||||
let visible = u64::from(width) * u64::from(height);
|
||||
assert!(rendered * 5 <= visible * 8); // At most 1.6× the visible pixels.
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag
|
||||
/// zooms to the dragged area. Takes the pointer from the other tools while it's active.
|
||||
fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: egui::Modifiers, typing: bool) -> bool {
|
||||
@@ -439,6 +505,10 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) {
|
||||
app.session.vector_pointer_left();
|
||||
}
|
||||
// A pan whose button was released off the canvas is over.
|
||||
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
|
||||
app.canvas.middle_pan = None;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// A press on the canvas gives the keyboard back to it (arrows nudge again).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! A double-click on the empty canvas, driven through egui headless: the gesture its second press
|
||||
//! began is dropped, so the tool is idle once the button is up.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// Two presses and releases at the bottom left of the canvas, with `modifiers` held.
|
||||
fn double_click(u: &mut Ui, modifiers: Modifiers) {
|
||||
let pos = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0);
|
||||
u.frame_with(vec![Event::PointerMoved(pos)], modifiers);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
u.frame_with(vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }], modifiers);
|
||||
}
|
||||
}
|
||||
u.frame_with(vec![], modifiers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_leaves_the_shape_tool_idle() {
|
||||
let mut s = Session::new();
|
||||
s.execute("pref.keepTool", &json!({"on": true})).unwrap();
|
||||
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
assert_eq!(u.app.inspect()["tool"]["busy"], false);
|
||||
double_click(&mut u, Modifiers::default());
|
||||
assert_eq!(u.app.inspect()["tool"]["busy"], false, "a finished double-click leaves the tool idle");
|
||||
assert!(u.app.session.execute("tool.place", &json!({})).is_ok(), "Enter places a shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_double_click_keeps_the_selection_handles() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap();
|
||||
let r = figcraft_engine::params::id_of(&r["id"]).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let handles = |u: &Ui| u.app.inspect()["tool"]["shapeHandles"].as_array().map_or(0, Vec::len);
|
||||
assert_eq!(handles(&u), 4);
|
||||
double_click(&mut u, Modifiers::SHIFT);
|
||||
assert_eq!(u.app.session.selection(), vec![r], "Shift keeps the selection");
|
||||
assert_eq!(u.app.inspect()["tool"]["busy"], false, "a finished double-click leaves the tool idle");
|
||||
assert_eq!(handles(&u), 4, "the corner radius handles show again");
|
||||
}
|
||||
@@ -154,19 +154,33 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
if let Err(e) = r {
|
||||
app.toast(e.to_string());
|
||||
}
|
||||
if let Some(c) = paint.solid_color() {
|
||||
app.session.recent_colors.retain(|x| *x != c);
|
||||
app.session.recent_colors.insert(0, c);
|
||||
app.session.recent_colors.truncate(16);
|
||||
// Drag previews (`live`) are not choices: a drag records only the colour it ends on. Not "an
|
||||
// interaction is open": editing text holds one, and its colour clicks are choices.
|
||||
if !live && let Some(c) = paint.solid_color() {
|
||||
remember_recent(app, c);
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_recent(app: &mut FigApp, c: Color) {
|
||||
// By 8-bit value: a document stores colours rounded to 8 bits, so the colour picked and the one read
|
||||
// back differ by a fraction and would show as two identical swatches.
|
||||
app.session.recent_colors.retain(|x| x.to_rgba8() != c.to_rgba8());
|
||||
app.session.recent_colors.insert(0, c);
|
||||
app.session.recent_colors.truncate(16);
|
||||
}
|
||||
|
||||
fn finish(app: &mut FigApp) {
|
||||
if let Some(PickerTarget::PluginField { plugin, control, pending: Some(hex) }) = app.ui.picker.clone() {
|
||||
crate::plugins_ui::set_field(app, &plugin, &control, &hex);
|
||||
app.ui.picker = Some(PickerTarget::PluginField { plugin, control, pending: None });
|
||||
}
|
||||
if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) {
|
||||
if let Some(t) = app.ui.picker.clone()
|
||||
&& !matches!(t, PickerTarget::PluginField { .. })
|
||||
&& let Some(c) = current_paint(app, &t).and_then(|p| p.solid_color())
|
||||
{
|
||||
remember_recent(app, c);
|
||||
}
|
||||
let _ = app.session.commit_interaction();
|
||||
}
|
||||
}
|
||||
@@ -212,11 +226,13 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
|
||||
/// Colour models the value row can show.
|
||||
const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"];
|
||||
|
||||
/// The model dropdown with its fields and the alpha field. Returns a committed colour.
|
||||
fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the
|
||||
/// edit is done (false while a field is being scrubbed).
|
||||
fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
|
||||
let model_id = Id::new("cp_model");
|
||||
let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1);
|
||||
let mut out: Option<Color> = None;
|
||||
let mut done = true;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -238,7 +254,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
w,
|
||||
1.0,
|
||||
) {
|
||||
Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x),
|
||||
Some(widgets::FieldEdit::Set(x)) => Some((x, true)),
|
||||
Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)),
|
||||
None => None,
|
||||
};
|
||||
match MODELS.get(model).copied().unwrap_or("Hex") {
|
||||
@@ -246,7 +263,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
let [r, g, b, _] = c.to_rgba8();
|
||||
let vals = [f64::from(r), f64::from(g), f64::from(b)];
|
||||
for (k, v) in vals.iter().enumerate() {
|
||||
if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
|
||||
if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
|
||||
done = end;
|
||||
let mut rgb = vals;
|
||||
if let Some(slot) = rgb.get_mut(k) {
|
||||
*slot = x.clamp(0.0, 255.0);
|
||||
@@ -266,7 +284,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
};
|
||||
let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0];
|
||||
for (k, v) in vals.iter().enumerate() {
|
||||
if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
|
||||
if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
|
||||
done = end;
|
||||
let mut v3 = vals;
|
||||
if let Some(slot) = v3.get_mut(k) {
|
||||
*slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) };
|
||||
@@ -284,18 +303,24 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
}
|
||||
_ => {
|
||||
if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") {
|
||||
out = Color::from_hex(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a }));
|
||||
out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if MODELS.get(model) != Some(&"CSS")
|
||||
&& let Some(widgets::FieldEdit::Set(v)) =
|
||||
widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0)
|
||||
{
|
||||
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
|
||||
if MODELS.get(model) != Some(&"CSS") {
|
||||
let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round());
|
||||
let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
|
||||
Some(widgets::FieldEdit::Set(v)) => Some((v, true)),
|
||||
Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)),
|
||||
None => None,
|
||||
};
|
||||
if let Some((v, end)) = edit {
|
||||
done = end;
|
||||
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
out.map(|c| (c, done))
|
||||
}
|
||||
|
||||
fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
|
||||
@@ -400,7 +425,15 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
|
||||
|
||||
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let Some(target) = app.ui.picker.clone() else { return };
|
||||
let Some(mut paint) = current_paint(app, &target) else {
|
||||
// A layer's picker belongs to the selection it was opened for: once that changes, close it
|
||||
// rather than keep editing layers that are no longer selected.
|
||||
let stale = match &target {
|
||||
PickerTarget::Paint { ids, .. } | PickerTarget::Effect { ids, .. } => *ids != app.session.selection(),
|
||||
PickerTarget::TextRange { id, .. } => !app.session.selection().contains(id),
|
||||
_ => false,
|
||||
};
|
||||
let current = if stale { None } else { current_paint(app, &target) };
|
||||
let Some(mut paint) = current else {
|
||||
app.ui.picker = None;
|
||||
return;
|
||||
};
|
||||
@@ -744,11 +777,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
done |= d;
|
||||
}
|
||||
// Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.
|
||||
if let Some(c) = model_fields(ui, nc) {
|
||||
if let Some((c, d)) = model_fields(ui, nc) {
|
||||
nc = c;
|
||||
hsb = Hsb::from_color(nc);
|
||||
changed = true;
|
||||
done = true;
|
||||
done |= d;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
|
||||
// A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
|
||||
@@ -1030,6 +1063,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(name).font(regular(11.0)).desired_width(136.0));
|
||||
crate::widgets::select_all_on_focus(ui, &r, name);
|
||||
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -1094,3 +1128,48 @@ impl WithStops for Gradient {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use figcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn dragging_records_only_the_final_colour_in_recent_colors() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let target = PickerTarget::PageBackground;
|
||||
u.app.ui.picker = Some(target.clone());
|
||||
for hex in ["#110000", "#220000", "#330000"] {
|
||||
let c = Color::from_hex(hex).unwrap();
|
||||
write(&mut u.app, &target, &Paint::solid(c), true);
|
||||
}
|
||||
assert!(u.app.session.recent_colors.is_empty(), "previews must not be recorded: {:?}", u.app.session.recent_colors);
|
||||
finish(&mut u.app);
|
||||
let last = Color::from_hex("#330000").unwrap();
|
||||
assert_eq!(u.app.session.recent_colors, vec![last]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_released_with_a_final_write_records_one_colour() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let target = PickerTarget::PageBackground;
|
||||
u.app.ui.picker = Some(target.clone());
|
||||
// Not a whole 8-bit value: the page stores it rounded, so recording both would add two near-identical swatches.
|
||||
let c = Color::new(0.3, 0.0, 0.0, 1.0);
|
||||
write(&mut u.app, &target, &Paint::solid(Color::new(0.1, 0.0, 0.0, 1.0)), true);
|
||||
write(&mut u.app, &target, &Paint::solid(c), false);
|
||||
finish(&mut u.app);
|
||||
assert_eq!(u.app.session.recent_colors.len(), 1, "{:?}", u.app.session.recent_colors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_outside_a_drag_still_records_the_colour() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let target = PickerTarget::PageBackground;
|
||||
u.app.ui.picker = Some(target.clone());
|
||||
let c = Color::from_hex("#445566").unwrap();
|
||||
write(&mut u.app, &target, &Paint::solid(c), false);
|
||||
assert_eq!(u.app.session.recent_colors, vec![c]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! The colour picker of a layer's paint or effect closes when that layer is no longer the
|
||||
//! selection, so it never keeps editing a layer that was deselected (#396).
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::colorpicker::PickerTarget;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn two() -> (Ui, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
let b = rect(&mut s, 40.0);
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
(Ui::new(s), a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_closes_when_its_layer_is_deselected() {
|
||||
for effect in [false, true] {
|
||||
let (mut u, a, _) = two();
|
||||
let target = if effect {
|
||||
u.app.run("effect.add", json!({"ids": [a], "type": "dropShadow"}));
|
||||
PickerTarget::Effect { ids: vec![a], index: 0, secondary: false }
|
||||
} else {
|
||||
PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }
|
||||
};
|
||||
u.app.ui.picker = Some(target.clone());
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.ui.picker, Some(target.clone()), "the picker opens while its layer is selected");
|
||||
u.app.run("select.none", json!({}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.picker.is_none(), "deselecting closes {target:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_closes_when_another_layer_is_selected() {
|
||||
let (mut u, a, b) = two();
|
||||
u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
|
||||
u.frame(vec![]);
|
||||
u.app.run("select.set", json!({"ids": [b]}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker);
|
||||
}
|
||||
@@ -472,7 +472,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
// Comment thread popover: an id opens it, null closes it.
|
||||
if let Some(v) = p.get("commentOpen") {
|
||||
match v.as_u64() {
|
||||
Some(id) => app.ui.comments.show(id),
|
||||
Some(id) => {
|
||||
// Pins are drawn for the current page only: reveal the thread's page first.
|
||||
let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
|
||||
if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
|
||||
app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
app.ui.comments.show(id);
|
||||
}
|
||||
None => app.ui.comments.close(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,8 +290,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(280.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0));
|
||||
crate::widgets::select_all_on_focus(ui, &r, &find);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0));
|
||||
crate::widgets::select_all_on_focus(ui, &r, &replace);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tr("Find")).clicked() {
|
||||
action = Some(false);
|
||||
@@ -393,11 +395,13 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
ui.set_width(320.0);
|
||||
ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary));
|
||||
ui.add_space(6.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0));
|
||||
crate::widgets::select_all_on_focus(ui, &r, &b.find);
|
||||
if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) {
|
||||
b.regex = !b.regex;
|
||||
}
|
||||
let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0));
|
||||
crate::widgets::select_all_on_focus(ui, &to, &b.to);
|
||||
ui.horizontal(|ui| {
|
||||
for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] {
|
||||
if ui.button(label).clicked() {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//! a scrub and an arrow key move a value, and the Design panel's fields driven through egui
|
||||
//! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on
|
||||
//! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or
|
||||
//! commit.
|
||||
//! commit (the colour picker's, the prototype panel's and the Scale panel's fields too).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2};
|
||||
use egui::{Event, Id, Key, Modifiers, Pos2, pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -216,6 +216,11 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
|
||||
/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go.
|
||||
fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
|
||||
let from = label(u, name);
|
||||
drag(u, from, dx, modifiers);
|
||||
}
|
||||
|
||||
/// Press at `from`, move `dx` points sideways in small steps with `modifiers` held, then let go.
|
||||
fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) {
|
||||
let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
u.frame_with(vec![Event::PointerMoved(from), press(true)], modifiers);
|
||||
let steps = 10;
|
||||
@@ -404,3 +409,67 @@ fn mixed_values_take_edits_relative_to_each_layer() {
|
||||
assert_eq!((geom(&u, a).0, geom(&u, b).0), (64.0, 64.0));
|
||||
assert_eq!(field(&mut u, "X").1, "64");
|
||||
}
|
||||
|
||||
/// The middle of the label (what scrubs) of the field with id `id`, as last painted.
|
||||
fn lead(u: &Ui, id: Id) -> Pos2 {
|
||||
u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
|
||||
let (mut u, a, _) = setup();
|
||||
let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8();
|
||||
let [r0, .., a0] = rgba(&u);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize));
|
||||
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
|
||||
// The picker window takes a frame to size itself.
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new(("cp_f", "r")));
|
||||
drag(&mut u, from, -20.0, Modifiers::NONE);
|
||||
assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R");
|
||||
// The alpha field: a scrub moves it too.
|
||||
let from = lead(&u, Id::new("cp_a"));
|
||||
drag(&mut u, from, -30.0, Modifiers::NONE);
|
||||
let [.., alpha] = rgba(&u);
|
||||
assert!(alpha < a0, "alpha {a0} → {alpha}");
|
||||
assert_eq!(undo_steps(&u), steps + 2, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
|
||||
let mut s = Session::new();
|
||||
let f1 = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap());
|
||||
let f2 = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap());
|
||||
let it = json!({"id": f1, "trigger": {"type": "afterDelay", "ms": 500}, "action": {"type": "navigate", "dest": f2}});
|
||||
s.execute("proto.interaction.add", &it).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [f1]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((f1, 0)));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new(("proto_delay", f1, 0usize)));
|
||||
drag(&mut u, from, 30.0, Modifiers::NONE);
|
||||
let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger {
|
||||
figcraft_doc::proto::Trigger::AfterDelay { ms } => ms,
|
||||
_ => panic!("still after a delay"),
|
||||
};
|
||||
assert_eq!((ms, undo_steps(&u)), (800, steps + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
let (mut u, a, _) = setup();
|
||||
u.app.run("tool.set", json!({"tool": "scale"}));
|
||||
u.frame(vec![]);
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new("scale_w"));
|
||||
drag(&mut u, from, 20.0, Modifiers::NONE);
|
||||
let (_, _, w, h) = geom(&u, a);
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Ruler guide drags released off the canvas, driven through egui headless: the gesture ends there,
|
||||
//! and a later click on the canvas neither adds a guide nor moves one.
|
||||
|
||||
use egui::{Event, Pos2, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn rulers() -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("view.rulers", &json!({"on": true})).unwrap();
|
||||
Ui::new(s)
|
||||
}
|
||||
|
||||
fn offsets(u: &Ui) -> Vec<f64> {
|
||||
let ds = u.app.session.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().guides().iter().map(|g| g.offset).collect()
|
||||
}
|
||||
|
||||
fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
|
||||
u.frame(vec![Event::PointerMoved(from)]);
|
||||
u.button(from, true);
|
||||
u.frame(vec![Event::PointerMoved(to)]);
|
||||
u.button(to, false);
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, p: Pos2) {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruler_drag_released_off_the_canvas_adds_no_guide_on_a_later_click() {
|
||||
for left in [false, true] {
|
||||
let mut u = rulers();
|
||||
let r = u.app.canvas.rect;
|
||||
let start = if left { Pos2::new(r.min.x + 5.0, r.min.y + 60.0) } else { Pos2::new(r.min.x + 60.0, r.min.y + 5.0) };
|
||||
drag(&mut u, start, Pos2::new(r.min.x - 100.0, r.center().y));
|
||||
let released = offsets(&u);
|
||||
click(&mut u, r.center() + vec2(30.0, 60.0));
|
||||
assert_eq!(offsets(&u), released, "left ruler: {left}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruler_drag_released_off_the_canvas_ends_even_when_the_next_frame_presses() {
|
||||
let mut u = rulers();
|
||||
let r = u.app.canvas.rect;
|
||||
let start = Pos2::new(r.min.x + 60.0, r.min.y + 5.0);
|
||||
let off = Pos2::new(r.min.x - 100.0, r.center().y);
|
||||
u.frame(vec![Event::PointerMoved(start)]);
|
||||
u.button(start, true);
|
||||
u.frame(vec![Event::PointerMoved(off)]);
|
||||
u.button(off, false);
|
||||
let p = r.center() + vec2(30.0, 60.0);
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
assert!(offsets(&u).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guide_moved_off_the_canvas_doesnt_follow_a_later_click() {
|
||||
let mut u = rulers();
|
||||
let r = u.app.canvas.rect;
|
||||
drag(&mut u, Pos2::new(r.min.x + 60.0, r.min.y + 5.0), r.center());
|
||||
assert_eq!(offsets(&u).len(), 1);
|
||||
drag(&mut u, r.center(), Pos2::new(r.min.x - 100.0, r.center().y));
|
||||
let released = offsets(&u);
|
||||
click(&mut u, r.center() + vec2(30.0, 60.0));
|
||||
assert_eq!(offsets(&u), released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruler_drag_released_on_the_canvas_adds_one_guide() {
|
||||
let mut u = rulers();
|
||||
let r = u.app.canvas.rect;
|
||||
drag(&mut u, Pos2::new(r.min.x + 60.0, r.min.y + 5.0), r.center());
|
||||
assert_eq!(offsets(&u).len(), 1);
|
||||
click(&mut u, r.center() + vec2(30.0, 60.0));
|
||||
assert_eq!(offsets(&u).len(), 1);
|
||||
u.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(offsets(&u).is_empty());
|
||||
}
|
||||
@@ -279,6 +279,19 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Ends a guide drag whose button is up by the end of the frame without [`input`] taking the
|
||||
/// release (released off the canvas, or over something on it): the gesture is dropped, so a later
|
||||
/// click doesn't add or move a guide.
|
||||
pub fn end_released(ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.pointer.primary_down()) {
|
||||
return;
|
||||
}
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<GuideDrag>(drag_id());
|
||||
d.remove::<Placed>(preview_id());
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the page's and its frames' guides (under the rulers) and the one being dragged with its
|
||||
/// position.
|
||||
pub fn draw(app: &FigApp, painter: &egui::Painter) {
|
||||
@@ -316,7 +329,9 @@ fn label(app: &FigApp, painter: &egui::Painter, r: Rect, g: &Placed) {
|
||||
let galley = painter.layout_no_wrap(crate::canvas::fmt(g.offset), semibold(10.0), Color32::WHITE);
|
||||
let size = galley.size() + vec2(8.0, 4.0);
|
||||
let [a, _] = segment(app, g, r);
|
||||
let at = pos2(a.x.clamp(r.min.x + RULER, r.max.x) + 4.0, a.y.clamp(r.min.y + RULER, r.max.y) + 4.0);
|
||||
// A canvas narrower or shorter than the ruler leaves `min > max`; keep the tag at the ruler edge then.
|
||||
let (lo_x, lo_y) = (r.min.x + RULER, r.min.y + RULER);
|
||||
let at = pos2(a.x.clamp(lo_x, r.max.x.max(lo_x)) + 4.0, a.y.clamp(lo_y, r.max.y.max(lo_y)) + 4.0);
|
||||
let tag = Rect::from_min_size(at, size);
|
||||
painter.rect_filled(tag, CornerRadius::same(3), GUIDE);
|
||||
painter.galley(tag.min + vec2(4.0, 2.0), galley, Color32::WHITE);
|
||||
|
||||
@@ -751,7 +751,7 @@ fn grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
|
||||
for (i, file) in row.iter().enumerate() {
|
||||
let rect = Rect::from_min_size(pos2(r.min.x + i as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h));
|
||||
let resp = ui.interact(rect, card_id(&file.path), Sense::click());
|
||||
a11y::button(&resp, &spoken(file, now));
|
||||
a11y::named_button(&resp, &spoken(file, now));
|
||||
let resp = resp.on_hover_text(&file.path);
|
||||
let visible = ui.is_rect_visible(rect);
|
||||
let picture = recents.picture(&file.path, visible, time);
|
||||
@@ -891,7 +891,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
|
||||
for file in files {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let resp = ui.interact(r, card_id(&file.path), Sense::click());
|
||||
a11y::button(&resp, &spoken(file, now));
|
||||
a11y::named_button(&resp, &spoken(file, now));
|
||||
let resp = resp.on_hover_text(&file.path);
|
||||
if let Some(a) = interact(ui, &resp, recents, file) {
|
||||
action = Some(a);
|
||||
|
||||
@@ -163,6 +163,9 @@ enum Plural {
|
||||
/// CLDR integer rules. Existing one/other catalogs may omit the extra forms.
|
||||
fn plural(code: &str, n: usize) -> Plural {
|
||||
match code {
|
||||
"cs" if n == 1 => Plural::One,
|
||||
"cs" if (2..=4).contains(&n) => Plural::Few,
|
||||
"cs" => Plural::Other,
|
||||
"ru" if n % 10 == 1 && n % 100 != 11 => Plural::One,
|
||||
"ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
|
||||
"ru" => Plural::Many,
|
||||
@@ -174,7 +177,7 @@ fn plural(code: &str, n: usize) -> Plural {
|
||||
}
|
||||
|
||||
// CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing
|
||||
// "other" wording. Only Russian currently supplies separate few/many forms.
|
||||
// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms.
|
||||
|
||||
/// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in
|
||||
/// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules).
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user