M0.9: scope Wayland decorations and avoid bundled title fonts

This commit is contained in:
Daniel Miguel
2026-10-16 00:01:55 +02:00
129 changed files with 5841 additions and 478 deletions
-9
View File
@@ -30,12 +30,3 @@ Release builds embed fonts from the optional craft-fonts build input
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
## Linux Wayland frame (Cargo dependency)
| Dependency asset | Title | Author | Source | Licence |
|---|---|---|---|---|
| `sctk-adwaita` 0.10.1: `src/title/Cantarell-Regular.ttf` | Cantarell Regular, unmodified fallback window-title font; Linux only | Understanding Limited; Jakub Steiner | [sctk-adwaita at d5f343c](https://github.com/PolyMeilex/sctk-adwaita/blob/d5f343c45c04d4a026db57f76760819b7325db00/src/title/Cantarell-Regular.ttf) | OFL-1.1; full copyright and licence in `NOTICE` |
The font is embedded by the dependency, with no font file added to this repository.
Adwaita window buttons are vector paths and do not depend on font glyphs.
Generated
+2 -27
View File
@@ -2,22 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ab_glyph"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
dependencies = [
"ab_glyph_rasterizer",
"owned_ttf_parser",
]
[[package]]
name = "ab_glyph_rasterizer"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "accesskit"
version = "0.24.1"
@@ -1437,9 +1421,11 @@ dependencies = [
"fmv-macos-events",
"image",
"log",
"raw-window-handle",
"rfd",
"serde_json",
"wat",
"wayland-client",
"webbrowser",
"windows",
"winit",
@@ -3221,15 +3207,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
"ttf-parser",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -4013,9 +3990,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"ab_glyph",
"log",
"memmap2",
"smithay-client-toolkit 0.19.2",
"tiny-skia 0.11.4",
]
+2 -95
View File
@@ -25,10 +25,8 @@ Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for Adwaita window decorations. Its unmodified fallback title font is
src/title/Cantarell-Regular.ttf (SIL OFL 1.1), embedded by the dependency;
no font file is copied into this repository. Window button icons are drawn
as vector paths and do not use the title font.
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
@@ -53,94 +51,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Cantarell Regular:
Copyright (c) 2009-2011, Understanding Limited (dave@understandinglimited.com),
Copyright (c) 2010-2011, Jakub Steiner (jimmac@gmail.com).
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+10 -6
View File
@@ -361,9 +361,13 @@ Forks and modified versions must remove them.
## Linux window controls
Linux desktop builds enable winit's Adwaita client-side frame on GNOME/Wayland,
where the compositor does not provide server-side decorations. Its minimize,
maximize/restore and close icons are drawn as vectors, so the close button does
not depend on a font containing a particular character. The frame follows the
desktop's button layout; X11 and compositors offering server-side decorations
keep their window manager's frame.
On Wayland without the `zxdg_decoration_manager_v1` protocol (including GNOME),
FigCraft enables winit's Adwaita client-side frame with vector minimize,
maximize/restore and close buttons. The tab strip keeps the document names;
the frame's title-font renderer is disabled, so this does not embed Cantarell.
X11 and Wayland compositors advertising server decorations, including KDE and
wlroots, retain FigCraft's existing frameless tab strip and its own controls.
The actual window backend is checked before requesting the frame. A failed or
slow capability query, or a launch using only an inherited Wayland socket,
keeps the existing custom controls. Desktop names are not used to guess capabilities.
+4 -5
View File
@@ -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-15 · **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-16 · **Change:** minor (Typography spacing icons support scrubbing (#373); 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
@@ -131,9 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
2026-10-15: Linux desktop builds enable the Adwaita Wayland frame and vector window
controls. See README → Linux window controls; full GTK theme parity is not claimed.
- **2026-10-16** · GNOME/Wayland controls: request the Adwaita frame only for an actual Wayland window whose compositor lacks xdg-decoration; retain the custom frameless tab strip on X11 and compositors with server decorations. Use vector buttons without the title-font renderer or embedded Cantarell. Failed/slow probes preserve the custom controls. See README → Linux window controls. Readiness estimates unchanged.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · 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.
@@ -317,6 +315,7 @@ controls. See README → Linux window controls; full GTK theme parity is not cla
| Date | Change | Summary |
|---|---|---|
| 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 |
+17 -1
View File
@@ -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");
+64 -34
View File
@@ -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() };
+10 -7
View File
@@ -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());
}
+4 -3
View File
@@ -45,6 +45,7 @@ wat = { workspace = true }
winresource = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME/Wayland has no server-side frame. Enable winit's Adwaita title bar and
# vector-drawn window buttons; eframe/wayland alone does not enable decorations.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita"] }
# Use vector window controls without a title-font renderer or embedded Cantarell.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
raw-window-handle = "0.6"
wayland-client = "0.31"
+7 -1
View File
@@ -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}")?;
}
+18 -2
View File
@@ -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();
+112
View File
@@ -0,0 +1,112 @@
//! Request winit's frame only on Wayland compositors without xdg-decoration.
//! Desktop/session names cannot identify the backend of an individual window.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
/// Never consume, duplicate or query that inherited connection: there may be no
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
pub fn display_socket() -> Option<PathBuf> {
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
return None;
}
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
let path = PathBuf::from(display);
if path.is_absolute() {
Some(path)
} else {
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
}
}
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
if !wayland {
log::debug!("window controls: non-Wayland window, retaining custom controls");
return false;
}
let server_decorations = socket.and_then(probe);
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
native_frame(wayland, server_decorations)
}
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
wayland && server_decorations == Some(false)
}
fn probe(socket: PathBuf) -> Option<bool> {
// The compositor must not be able to block startup on a registry roundtrip.
// On failure/timeout retain FigCraft's own working title bar and buttons.
let (send, receive) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("wayland-decorations".into())
.spawn(move || {
let _ = send.send(registry_has_decorations(socket));
})
.ok()?;
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
}
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
// A fresh connection leaves winit's event queue and inherited descriptors alone.
let stream = UnixStream::connect(socket).ok()?;
let connection = Connection::from_socket(stream).ok()?;
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
}
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_queue: &QueueHandle<Self>,
) {
}
}
#[cfg(test)]
mod tests {
#[test]
fn only_wayland_without_server_decorations_uses_the_frame() {
for (wayland, server_decorations, expected) in [
(false, None, false),
(false, Some(false), false),
(false, Some(true), false),
(true, Some(true), false),
(true, Some(false), true),
(true, None, false),
] {
assert_eq!(super::native_frame(wayland, server_decorations), expected);
}
}
#[test]
fn an_unavailable_display_keeps_the_custom_controls() {
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
}
#[test]
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
let start = std::time::Instant::now();
assert_eq!(super::probe(socket.clone()), None);
assert!(start.elapsed() < std::time::Duration::from_secs(2));
drop(listener);
let _ = std::fs::remove_file(socket);
Ok(())
}
}
+15 -11
View File
@@ -15,6 +15,8 @@ mod args;
mod control_server;
mod gamepad;
mod graphics;
#[cfg(target_os = "linux")]
mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -192,7 +194,8 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
// Other windows inherit this choice, so they keep the same native frame on Linux.
// Start frameless on Linux; the app creator checks the actual display backend
// before enabling a client-side frame. Other windows inherit that decision.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
if let Some(icon) = icon {
@@ -201,6 +204,8 @@ fn main() -> eframe::Result {
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
#[cfg(target_os = "linux")]
let linux_display = linux_window::display_socket();
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -211,6 +216,11 @@ fn main() -> eframe::Result {
"FigCraft",
options,
Box::new(move |_cc| {
#[cfg(target_os = "linux")]
if linux_window::needs_native_frame(_cc, linux_display) {
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
}
Ok(Box::new(App {
queue,
ctx_slot,
@@ -230,13 +240,7 @@ fn main() -> eframe::Result {
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
use figcraft_ui_egui::TitleBar;
if cfg!(target_os = "macos") {
TitleBar::Inset
} else if cfg!(target_os = "linux") {
TitleBar::Native
} else {
TitleBar::Custom
}
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
}
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
@@ -251,10 +255,10 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T
#[cfg(all(test, target_os = "linux"))]
mod linux_window_tests {
#[test]
fn the_desktop_and_detached_windows_use_native_chrome() {
fn linux_starts_with_the_existing_frameless_title_bar() {
let bar = super::desktop_title_bar();
assert_eq!(bar, figcraft_ui_egui::TitleBar::Native);
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(true));
assert_eq!(viewport.decorations, Some(false));
}
}
+5
View File
@@ -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
View File
@@ -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.
+197
View File
@@ -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
View File
@@ -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]
+13 -1
View File
@@ -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 {
+19
View File
@@ -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())));
}
+22
View File
@@ -381,3 +381,25 @@ 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_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());
}
+2
View File
@@ -675,11 +675,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);
}
+19 -2
View File
@@ -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));
}
}
+13 -4
View File
@@ -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
+35
View File
@@ -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();
+21
View File
@@ -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();
+12 -3
View File
@@ -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))
}
+33 -10
View File
@@ -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,23 @@ 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
// Each layer and its copy (also those in sections), so flows can follow their start frames.
let mut copies = Vec::new();
for c in doc.get(src)?.children.clone() {
let copy = doc.duplicate_into(c, np, None)?;
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 +137,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 +183,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()
}),
+3
View File
@@ -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(),
+1 -1
View File
@@ -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)) => {
+10 -2
View File
@@ -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();
+51 -1
View File
@@ -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;
+10 -7
View File
@@ -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}");
}
}
+18
View File
@@ -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.
+26
View File
@@ -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)]);
}
+2 -2
View File
@@ -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());
+5 -2
View File
@@ -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)]),
}
}
+40
View File
@@ -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}");
}
}
}
+6 -2
View File
@@ -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,6 +1558,8 @@ 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 live_access_tests;
@@ -1586,6 +1588,8 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
+23 -3
View File
@@ -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, &params).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);
}
+63
View File
@@ -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");
}
+1 -1
View File
@@ -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 {
+19 -4
View File
@@ -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");
}
}
}
+15 -8
View File
@@ -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(())
@@ -146,9 +147,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
+46
View File
@@ -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"),
}
}
+5 -3
View File
@@ -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);
}
+96 -4
View File
@@ -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:?}");
}
}
+17
View File
@@ -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, &params).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));
}
+26
View File
@@ -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);
}
+32
View File
@@ -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();
+7 -6
View File
@@ -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)))
{
+31
View File
@@ -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) {
+12 -3
View File
@@ -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();
+4 -1
View File
@@ -126,7 +126,10 @@ 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()),
_ => {}
}
}
@@ -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);
}
+13 -2
View File
@@ -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",
+47
View File
@@ -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));
}
+7 -3
View File
@@ -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()
};
+1 -1
View File
@@ -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);
+60
View File
@@ -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}");
}
+14 -4
View File
@@ -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");
}
+5 -2
View File
@@ -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
View File
@@ -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);
}
}
+123
View File
@@ -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} |");
}
+135
View File
@@ -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:?}");
}
}
}
}
+4
View File
@@ -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;
+25
View File
@@ -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})");
}
}
}
+68
View File
@@ -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)]
+33
View File
@@ -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}");
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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));
+1
View File
@@ -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)) {
+5
View File
@@ -183,6 +183,7 @@ 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);
@@ -439,6 +440,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");
}
+98 -19
View File
@@ -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);
}
+8 -1
View File
@@ -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(),
}
}
+7 -3
View File
@@ -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() {
+71 -2
View File
@@ -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");
}
+85
View File
@@ -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());
}
+16 -1
View File
@@ -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);
+2 -2
View File
@@ -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);
+14 -1
View File
@@ -322,10 +322,23 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
// An OS list: the first language there is a catalog for (English counts).
assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de");
assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en");
assert_eq!(code("system", Some("nl,sv")), "en");
assert_eq!(code("system", Some("nl,da")), "en");
assert_eq!(code("system", Some("nl,sv")), "sv");
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
}
#[test]
fn swedish_is_picked_for_swedish_locales() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
for tag in ["sv", "sv-SE", "sv-FI", "sv_SE.UTF-8"] {
assert_eq!(code("system", Some(tag)), "sv", "{tag}");
}
let sv = LANGS.iter().position(|l| l.code == "sv").unwrap();
assert_eq!(LANGS[sv].name, "Svenska");
assert!(!LANGS[sv].draft);
assert!(!plural_one(sv, 0) && plural_one(sv, 1) && !plural_one(sv, 2));
}
#[test]
fn latin_american_spanish_overlays_spanish() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
+19
View File
@@ -190,6 +190,25 @@ fn a_toast_wakes_the_ui_only_to_go_away() {
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_toast_raised_by_a_slow_frame_still_shows_for_3_s() {
let (mut h, _) = document();
h.settle();
// The Open dialog and a failed open block the frame that raises the error: the next frame
// comes 5 s after it started.
h.app.toast("can't open file.fig");
h.frame(vec![]);
h.time += 5.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_some(), "shown, not expired by the wait");
h.time += 2.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_some());
h.time += 1.5;
h.frame(vec![]);
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_finished_background_render_wakes_the_ui() {
let ctx = egui::Context::default();
+1
View File
@@ -591,6 +591,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
.vertical_align(egui::Align::Center)
.desired_width(te_rect.width()),
);
crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
+49 -6
View File
@@ -73,10 +73,14 @@ mod async_dialog_tests;
#[cfg(test)]
mod branch_ui_tests;
#[cfg(test)]
mod canvas_double_click_tests;
#[cfg(test)]
mod canvas_drag_tests;
#[cfg(test)]
mod canvas_fixes_tests;
#[cfg(test)]
mod colorpicker_selection_tests;
#[cfg(test)]
mod comments_tests;
#[cfg(test)]
mod cursors_tests;
@@ -93,6 +97,8 @@ mod gap_handle_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_release_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
@@ -127,6 +133,8 @@ mod online_tests;
#[cfg(test)]
mod open_issues_tests;
#[cfg(test)]
mod panel_remove_tests;
#[cfg(test)]
mod panel_reorder_tests;
#[cfg(test)]
mod panel_width_tests;
@@ -151,6 +159,8 @@ mod proto_reorder_tests;
#[cfg(test)]
mod proto_responsive_tests;
#[cfg(test)]
mod select_all_tests;
#[cfg(test)]
mod shortcut_keys_tests;
#[cfg(test)]
mod shortcuts_tests;
@@ -331,6 +341,17 @@ pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file:
/// How long a toast shows (seconds).
const TOAST_SECS: f64 = 3.0;
/// How long a toast has been showing ([`UiState::toast`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ToastClock {
/// Raised, not drawn yet.
Raised,
/// Drawn once; the next frame starts the clock.
Drawn,
/// Showing since this time (UI clock, seconds).
Since(f64),
}
/// Draw another frame `secs` from now (nothing else needs drawing until then: an idle window draws
/// no frames).
#[track_caller]
@@ -396,8 +417,8 @@ pub struct UiState {
pub title_bar: TitleBar,
/// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`).
pub icon_gallery: bool,
/// A message at the bottom of the window and when it first showed (UI clock, seconds).
pub toast: Option<(String, Option<f64>)>,
/// A message at the bottom of the window and how long it has been showing.
pub toast: Option<(String, ToastClock)>,
pub present: Option<present::Presenter>,
/// The full-window presenter's options (how it sizes the prototype), kept between
/// presentations.
@@ -734,7 +755,7 @@ impl FigApp {
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.ui.toast = Some((msg.into(), None));
self.ui.toast = Some((msg.into(), ToastClock::Raised));
}
/// Say that `n` documents left unsaved by a crash were opened again from recovery copies.
@@ -1061,6 +1082,9 @@ impl FigApp {
self.language = Some(self.session.prefs.language.clone());
}
if !self.fonts_installed {
// Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's
// own keyboard zoom would scale the whole interface on the same keys.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
theme::install_fonts(ctx, false, 0);
self.fonts_installed = true;
} else if figcraft_text::system_fonts_scanned() {
@@ -1373,16 +1397,32 @@ impl FigApp {
}
fn toast_ui(&mut self, ctx: &egui::Context) {
let Some((msg, shown)) = &mut self.ui.toast else { return };
let Some((msg, clock)) = &mut self.ui.toast else { return };
let now = ctx.input(|i| i.time);
let left = TOAST_SECS - (now - *shown.get_or_insert(now));
// The clock starts on the frame after the one that first draws the toast: that frame's
// time was taken before whatever raised it ran (a file dialog, a long open), and would
// count that wait against it, so a failed open's message expired before it was seen.
let left = match *clock {
ToastClock::Raised => {
*clock = ToastClock::Drawn;
ctx.request_repaint();
TOAST_SECS
}
ToastClock::Drawn => {
*clock = ToastClock::Since(now);
TOAST_SECS
}
ToastClock::Since(t) => TOAST_SECS - (now - t),
};
if left <= 0.0 {
self.ui.toast = None;
return;
}
let msg = msg.clone();
// Nothing moves while it shows: the next frame it needs is the one that hides it.
repaint_in(ctx, left);
if matches!(clock, ToastClock::Since(_)) {
repaint_in(ctx, left);
}
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new("toast"))
.fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0))
@@ -1466,3 +1506,6 @@ impl FigApp {
})
}
}
#[cfg(test)]
mod typography_scrub_tests;
+16
View File
@@ -181,6 +181,22 @@ pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usiz
});
}
/// Keep per-row flags (`Id::new((salt, index))`) with their rows when item `removed` of a
/// `len`-long list goes; its own flag goes with it.
pub fn remove_flags(ui: &Ui, salt: &'static str, len: usize, removed: usize) {
ui.data_mut(|d| {
let flags: Vec<Option<bool>> = (0..len).map(|i| d.get_temp::<bool>(Id::new((salt, i)))).collect();
for i in 0..len {
d.remove::<bool>(Id::new((salt, i)));
}
for (i, f) in flags.into_iter().enumerate() {
if let (Some(j), Some(v)) = (figcraft_engine::cmd::lists::removed_index(i, removed), f) {
d.insert_temp(Id::new((salt, j)), v);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -1002,6 +1002,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
.id(egui::Id::new("live_report_reason")),
);
a11y::text(&resp, tr("Report"), &reason);
crate::widgets::select_all_on_focus(ui, &resp, &reason);
if std::mem::take(&mut app.ui.live.focus_report) {
resp.request_focus();
}
@@ -1163,6 +1164,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
);
// The address may carry the server key: it isn't read out.
a11y::text(&resp, tr("Server address"), "");
crate::widgets::select_all_on_focus(ui, &resp, &form.server);
if focus {
resp.request_focus();
}
@@ -1192,6 +1194,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
widgets::label(ui, tr("File name"), t.text_secondary);
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
a11y::text(&nresp, tr("File name"), &form.name);
crate::widgets::select_all_on_focus(ui, &nresp, &form.name);
go |= nresp.lost_focus() && enter(ui) && (keyed || open_server);
ui.add_space(4.0);
ui.horizontal(|ui| {
+12
View File
@@ -84,6 +84,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"file.saveVersion" => crate::versions::start_save(app),
"branch.create" => crate::versions::start_branch(app),
"file.export" | "file.exportSvg" => app.ui.export_preview = true,
"file.exportFramesPdf" => export_frames_pdf(app),
"edit.copyAsCss" => {
let ids = app.session.selection();
if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) {
@@ -156,6 +157,16 @@ pub fn invoke(app: &mut FigApp, id: &str) {
}
}
/// Every visible top-level frame of the page as one PDF page each, through a save dialog.
fn export_frames_pdf(app: &mut FigApp) {
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into());
app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| {
if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() {
app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
}
});
}
fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
String::from_utf8(bytes).ok()
@@ -627,6 +638,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) {
.frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))),
);
r.request_focus();
crate::widgets::select_all_on_focus(ui, &r, &q);
ui.add_space(4.0);
let ql = q.to_lowercase();
// Labels match in the UI language and in English.

Some files were not shown because too many files have changed in this diff Show More