mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
Merge main and the community batch into #279
This commit is contained in:
@@ -29,6 +29,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 35
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -69,6 +70,7 @@ jobs:
|
||||
run: cargo run --locked -q -p xtask -- assets
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -19,6 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
|
||||
@@ -41,6 +41,7 @@ env:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
timeout-minutes: 10
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
@@ -69,6 +70,7 @@ jobs:
|
||||
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
timeout-minutes: 90
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
@@ -127,6 +129,7 @@ jobs:
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
timeout-minutes: 90
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
@@ -187,6 +190,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
timeout-minutes: 90
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
@@ -252,6 +256,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
server:
|
||||
timeout-minutes: 90
|
||||
name: Server Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
|
||||
@@ -295,6 +300,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
timeout-minutes: 90
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
@@ -388,6 +394,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
timeout-minutes: 90
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
@@ -433,6 +440,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
timeout-minutes: 90
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
@@ -502,6 +510,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
timeout-minutes: 10
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
Generated
+33
-21
@@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
"figcraft-format",
|
||||
"figcraft-text",
|
||||
"figcraft-ui-egui",
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"rfd",
|
||||
@@ -1430,7 +1431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1444,7 +1445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1459,7 +1460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1482,7 +1483,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1491,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1503,7 +1504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1530,7 +1531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1545,7 +1546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1553,7 +1554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1561,7 +1562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1577,7 +1578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1586,7 +1587,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1603,7 +1604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1616,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1634,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1658,7 +1659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1674,7 +1675,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1687,7 +1688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1711,7 +1712,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1750,6 +1751,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -5807,7 +5819,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
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
|
||||
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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.
|
||||
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
|
||||
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
|
||||
@@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
|
||||
|
||||
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_takes_the_documented_gradient_shorthand() {
|
||||
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
|
||||
let d = tmp("gradient");
|
||||
let script = d.join("s.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
|
||||
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
|
||||
)
|
||||
.unwrap();
|
||||
let file = d.join("g.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
let json = d.join("g.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let doc = std::fs::read_to_string(&json).unwrap();
|
||||
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
|
||||
@@ -800,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() {
|
||||
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
|
||||
let dir = TempDir::new("libfail");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
// Version 1, then a changed library as version 2.
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
|
||||
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
|
||||
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
|
||||
let files = dir.path().join(crate::store::FILES).join(&file.id);
|
||||
// The description can't be written: a directory sits where its temporary file goes.
|
||||
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
|
||||
std::fs::create_dir(&tmp).unwrap();
|
||||
assert!(store.publish_library(&file.id, &v2).is_err());
|
||||
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
let served = |reply: LibraryReply| match reply {
|
||||
LibraryReply::Library { version, archive } => (version, archive),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
|
||||
// A crash right after the archive was written leaves it on disk, the description unchanged.
|
||||
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
|
||||
drop(store);
|
||||
std::fs::remove_dir(&tmp).unwrap();
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
|
||||
// Publishing again works, and serves the new version.
|
||||
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
|
||||
assert_eq!(served(fetch(&mut store)), (2, v2));
|
||||
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! and a join code's hash and wrapped token (see `codes`)
|
||||
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
|
||||
//! <data>/files/<id>/log.bin what changed since (see `disk`)
|
||||
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
|
||||
//! <data>/files/<id>/library-<version>.bin its team library, when one was published (a `.figcraft`
|
||||
//! archive; `library.bin` from servers before versioned names)
|
||||
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
|
||||
//! <data>/plugins/ the plugin directory (see `directory`)
|
||||
//! <data>/blocked.json addresses the operator blocked
|
||||
@@ -179,7 +180,7 @@ impl Meta {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a file's team library is (`library.bin` holds it).
|
||||
/// What a file's team library is ([`library_file`] holds it).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct LibraryMeta {
|
||||
id: String,
|
||||
@@ -265,6 +266,9 @@ pub struct Store {
|
||||
next_sweep: u64,
|
||||
/// Compactions so far (tests count them).
|
||||
compactions: u64,
|
||||
/// Open files closed by a compaction cut short (their snapshot replaced, their log not):
|
||||
/// reported at the next flush, so nothing appended after it is confirmed.
|
||||
cut_short: Vec<String>,
|
||||
/// The free space last seen on the data directory's disk, and when.
|
||||
free: Option<(Instant, Option<u64>)>,
|
||||
_lock: File,
|
||||
@@ -360,6 +364,7 @@ impl Store {
|
||||
gone_codes: VecDeque::new(),
|
||||
next_sweep: 0,
|
||||
compactions: 0,
|
||||
cut_short: Vec::new(),
|
||||
free: None,
|
||||
_lock: lock,
|
||||
};
|
||||
@@ -919,11 +924,12 @@ impl Store {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
/// everyone may download (the size is known before anything is read).
|
||||
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
|
||||
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
|
||||
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
|
||||
if have == Some(lib.version) {
|
||||
return Ok(LibraryReply::Unchanged { version: lib.version });
|
||||
}
|
||||
let path = library_file(&dir, lib.version);
|
||||
let size = fs::metadata(&path).map_or(0, |m| m.len());
|
||||
let now = Instant::now();
|
||||
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
|
||||
@@ -965,14 +971,24 @@ impl Store {
|
||||
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
|
||||
}
|
||||
}
|
||||
// The archive first: a description never names a version whose library isn't there (a
|
||||
// failed description write leaves the older number, which the next publish follows).
|
||||
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
// Each version has a file of its own, written before the description names it: a failed
|
||||
// description write or a crash in between leaves the previous version served whole, never
|
||||
// the new bytes under the old number.
|
||||
let path = dir.join(library_name(m.version));
|
||||
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
|
||||
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
|
||||
// Never served (the description doesn't name it); the next publish of it replaces it.
|
||||
if let Err(r) = fs::remove_file(&path) {
|
||||
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
|
||||
}
|
||||
entry.meta.library = before;
|
||||
return Err(format!("couldn't keep the library: {e}"));
|
||||
}
|
||||
// The previous version isn't served any more.
|
||||
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
|
||||
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
|
||||
}
|
||||
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
|
||||
Ok(m.version)
|
||||
}
|
||||
@@ -1124,7 +1140,7 @@ impl Store {
|
||||
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
|
||||
}
|
||||
// A new snapshot and log of the next generation, so appends start clean.
|
||||
_ => self.rewrite(file, generation + 1, &server)?,
|
||||
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
|
||||
};
|
||||
self.entry(file)?.log = Some(log);
|
||||
Ok(server)
|
||||
@@ -1137,21 +1153,24 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
|
||||
let dir = self.dir(file);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
||||
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
|
||||
let state = server.state().map_err(|e| kept(e.to_string()))?;
|
||||
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
|
||||
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
|
||||
// and log still hold everything.
|
||||
if bytes.len() as u64 > self.snapshot_cap() {
|
||||
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
|
||||
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
|
||||
}
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
|
||||
// From here the old log no longer goes with the snapshot on disk.
|
||||
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.bytes = (bytes.len() + LOG_HEADER) as u64;
|
||||
e.meta.seq = server.seq();
|
||||
@@ -1204,8 +1223,19 @@ impl Store {
|
||||
}
|
||||
log::debug!("compacted file {file}");
|
||||
}
|
||||
// The new snapshot holds everything, but a restart would drop whatever is appended to
|
||||
// the old log now (its generation no longer matches): the file closes, the hub hears
|
||||
// it at the next flush, and it opens again from the new snapshot.
|
||||
Err(RewriteError { why, snapshot_replaced: true }) => {
|
||||
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
|
||||
log::error!("file {file}: compaction was cut short: {why}");
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
self.cut_short.push(file.to_string());
|
||||
}
|
||||
// The old snapshot and log still hold everything: carry on appending.
|
||||
Err(e) => log::error!("file {file}: compaction failed: {e}"),
|
||||
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,7 +1243,7 @@ impl Store {
|
||||
/// Those are closed here (their pending records dropped: nobody was told of them) and open
|
||||
/// again from what is on disk.
|
||||
fn flush_all(&mut self) -> Vec<String> {
|
||||
let mut failed = Vec::new();
|
||||
let mut failed = std::mem::take(&mut self.cut_short);
|
||||
for (id, e) in &mut self.files {
|
||||
if let Some(log) = e.log.as_mut() {
|
||||
match flush_log(id, log) {
|
||||
@@ -1229,6 +1259,13 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
|
||||
/// before it failed (then the old log no longer goes with it).
|
||||
struct RewriteError {
|
||||
why: String,
|
||||
snapshot_replaced: bool,
|
||||
}
|
||||
|
||||
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
|
||||
/// can't be trusted to hold them).
|
||||
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
@@ -1249,6 +1286,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the file holding version `version` of a file's team library.
|
||||
pub fn library_name(version: u64) -> String {
|
||||
format!("library-{version}.bin")
|
||||
}
|
||||
|
||||
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
|
||||
/// version published before each version had one.
|
||||
fn library_file(dir: &Path, version: u64) -> PathBuf {
|
||||
let path = dir.join(library_name(version));
|
||||
if path.exists() { path } else { dir.join(LIBRARY) }
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
disk::write_atomic(path, &text).map_err(|e| e.to_string())
|
||||
@@ -1316,6 +1365,8 @@ impl Backend for Store {
|
||||
if dirty {
|
||||
self.compact(file, server);
|
||||
}
|
||||
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
|
||||
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
|
||||
let _ = disk::read_log(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first page's name in `server`'s document.
|
||||
fn page_name(server: &Server) -> String {
|
||||
let d = server.replica().document();
|
||||
d.node(d.first_page().unwrap()).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
|
||||
let dir = TempDir::new("cutshort");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let folder = dir.path().join(FILES);
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
// The next compaction writes its snapshot, then can't start its log.
|
||||
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
|
||||
fs::create_dir(&blocker).unwrap();
|
||||
// What the hub would confirm: every change whose flush didn't report the file.
|
||||
let mut confirmed = String::new();
|
||||
let mut failed = false;
|
||||
let names = ["P0", "P1", "P2", "After 1", "After 2"];
|
||||
for name in names {
|
||||
rename(&mut server, &mut a, name);
|
||||
store.changed(&id, &mut server);
|
||||
if store.flush().contains(&id) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
confirmed = name.to_string();
|
||||
}
|
||||
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
|
||||
// A crash, then the disk recovers.
|
||||
drop(store);
|
||||
fs::remove_dir(&blocker).unwrap();
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let restored = store.open(&id).unwrap();
|
||||
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
|
||||
let at = |n: &str| names.iter().position(|x| *x == n);
|
||||
let name = page_name(&restored);
|
||||
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn start() {
|
||||
// on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
)
|
||||
@@ -69,14 +69,22 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
|
||||
/// goes through egui's own web clipboard handling.
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the
|
||||
/// system clipboard through egui's output; pastes arrive as paste events with their text (a page
|
||||
/// can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
|
||||
|
||||
@@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
|
||||
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
|
||||
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! macOS open-documents and quit Apple events (#289).
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
|
||||
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
|
||||
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
|
||||
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
|
||||
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
|
||||
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use serde_json::json;
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => open_documents(fig, paths),
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
|
||||
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
|
||||
for p in paths {
|
||||
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
fig.ui.home = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
|
||||
//! a drop on the Dock icon) open as File › Open opens them (#289).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, NoServices};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::apple_events::open_documents;
|
||||
|
||||
#[test]
|
||||
fn documents_from_finder_open_and_leave_the_home_screen() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("BLA-001.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
app.ui.home = true;
|
||||
// A file that can't be read is reported and doesn't keep the others from opening.
|
||||
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
|
||||
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
|
||||
assert_eq!(paths, [Some(file)]);
|
||||
assert!(!app.ui.home, "the opened document shows");
|
||||
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
@@ -16,6 +18,8 @@ mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod apple_events_tests;
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
@@ -43,6 +47,9 @@ struct App {
|
||||
title: String,
|
||||
/// Shared with the services, which write the copies after a panic.
|
||||
recovery: recovery::Shared,
|
||||
/// Documents and quit requests from Finder, the Dock and Open With.
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: fmv_macos_events::Inbox,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -55,6 +62,8 @@ impl eframe::App for App {
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
// windows are open).
|
||||
figcraft_ui_egui::windows::on_close_request(fig, ctx);
|
||||
@@ -188,5 +197,24 @@ 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);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
// 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")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
title: String::new(),
|
||||
recovery,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&_cc.egui_ctx),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,14 +41,40 @@ pub struct Recovery {
|
||||
dir: PathBuf,
|
||||
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
|
||||
_lock: File,
|
||||
/// The revision each document was last copied at, and the write in flight.
|
||||
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
|
||||
/// What was copied of each document, and the write in flight.
|
||||
written: HashMap<u64, Copy>,
|
||||
/// When (UI clock) the next copies are due.
|
||||
next: f64,
|
||||
/// Copies of online files from earlier runs, kept until their outcome is known.
|
||||
waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
/// The state of the copy of one document: only a write that succeeded counts as a copy.
|
||||
#[derive(Default)]
|
||||
struct Copy {
|
||||
/// The revision whose copy was written.
|
||||
done: Option<u64>,
|
||||
/// The write in flight: the revision it copies, and whether it succeeded once it ends.
|
||||
flight: Option<(u64, JoinHandle<bool>)>,
|
||||
}
|
||||
|
||||
impl Copy {
|
||||
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
|
||||
fn reap(&mut self) {
|
||||
if self.flight.as_ref().is_some_and(|f| f.1.is_finished())
|
||||
&& let Some((revision, h)) = self.flight.take()
|
||||
&& h.join().unwrap_or(false)
|
||||
{
|
||||
self.done = Some(revision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `revision` has neither been copied nor is being copied.
|
||||
fn pending(&self, revision: u64) -> bool {
|
||||
self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision)
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of one of your online files whose file is being joined again as session `session`.
|
||||
pub struct Waiting {
|
||||
session: u64,
|
||||
@@ -86,15 +112,21 @@ impl Recovery {
|
||||
/// an idle window can sleep).
|
||||
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
|
||||
self.settle(s);
|
||||
self.written.values_mut().for_each(Copy::reap);
|
||||
// A join on its way settles within its timeouts: look again soon.
|
||||
let settling = (!self.waiting.is_empty()).then_some(0.5);
|
||||
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
|
||||
if now < self.next {
|
||||
return pending.then_some(self.next - now).or(settling);
|
||||
return self.pending(s).then_some(self.next - now).or(settling);
|
||||
}
|
||||
self.write(s, false);
|
||||
self.next = now + INTERVAL;
|
||||
settling
|
||||
// A copy that failed is tried again at the next turn, without another edit.
|
||||
settling.or(self.pending(s).then_some(INTERVAL))
|
||||
}
|
||||
|
||||
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
|
||||
fn pending(&self, s: &Session) -> bool {
|
||||
s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision)))
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
|
||||
@@ -131,9 +163,10 @@ impl Recovery {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let entry = self.written.entry(d.uid).or_insert((0, None));
|
||||
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
|
||||
if entry.0 == d.revision || busy {
|
||||
let entry = self.written.entry(d.uid).or_default();
|
||||
entry.reap();
|
||||
// A write still in flight (for any revision) is waited for; a failed one is tried again.
|
||||
if entry.done == Some(d.revision) || entry.flight.is_some() {
|
||||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
@@ -144,20 +177,22 @@ impl Recovery {
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
true
|
||||
};
|
||||
// The revision counts as copied only once its write succeeded.
|
||||
if wait {
|
||||
job();
|
||||
entry.1 = None;
|
||||
if job() {
|
||||
entry.done = Some(d.revision);
|
||||
}
|
||||
} else {
|
||||
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
|
||||
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h));
|
||||
}
|
||||
entry.0 = d.revision;
|
||||
}
|
||||
// Closed documents.
|
||||
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
@@ -177,7 +212,7 @@ impl Recovery {
|
||||
pub fn finish(self) {
|
||||
let Self { dir, _lock, written, .. } = self;
|
||||
// Windows removes a folder only once nothing in it is open: copies being written first.
|
||||
for h in written.into_values().filter_map(|w| w.1) {
|
||||
for (_, h) in written.into_values().filter_map(|w| w.flight) {
|
||||
let _ = h.join();
|
||||
}
|
||||
drop(_lock);
|
||||
|
||||
@@ -62,3 +62,56 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
|
||||
assert!(!dir.exists(), "a normal quit removes the copies");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The files named `*.figcraft` in the run folder under `root`.
|
||||
fn copy_count(root: &std::path::Path) -> usize {
|
||||
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
|
||||
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
|
||||
}
|
||||
|
||||
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
|
||||
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
|
||||
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
|
||||
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
|
||||
std::fs::create_dir(&blocker).unwrap();
|
||||
blocker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
let root = root("retry");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
|
||||
// away fails on Windows, where the run's lock file keeps it open).
|
||||
let blocker = blocked_copy(&root, &s);
|
||||
r.write(&s, true);
|
||||
std::fs::remove_dir(&blocker).unwrap();
|
||||
// Nothing was edited since: the same revision still has to be copied.
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
let root = root("retry-bg");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let blocker = blocked_copy(&root, &s);
|
||||
r.write(&s, false);
|
||||
// Let the failing write end before the way is clear again.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
std::fs::remove_dir(&blocker).unwrap();
|
||||
for _ in 0..50 {
|
||||
r.write(&s, false);
|
||||
if copy_count(&root) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -59,12 +59,16 @@ impl Desktop {
|
||||
|
||||
/// The system's preferred interface languages, most preferred first, comma-separated
|
||||
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
|
||||
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
|
||||
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
|
||||
/// Windows keeps only its first preferred language (and English when it has no catalog): that
|
||||
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
|
||||
/// system) must not decide the interface language.
|
||||
fn system_language() -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::System::UserProfile::GlobalizationPreferences::Languages()
|
||||
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
|
||||
.and_then(|l| l.GetAt(0))
|
||||
.map(|s| s.to_string())
|
||||
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -16,3 +16,14 @@
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.tylerjenningsw]
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -307,7 +307,8 @@ impl Client {
|
||||
/// Queue `changes` as a new batch (already shown in the view).
|
||||
fn queue(&mut self, changes: Vec<Change>, kind: BatchKind, step: Option<u64>) {
|
||||
let batch_id = self.next_batch;
|
||||
self.next_batch += 1;
|
||||
// `next_batch` starts from the welcome's `last_batch`, the host's word: saturate.
|
||||
self.next_batch = self.next_batch.saturating_add(1);
|
||||
self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false });
|
||||
}
|
||||
|
||||
@@ -484,7 +485,11 @@ impl Client {
|
||||
|
||||
/// A batch the server accepted (in order). Follow-ups it causes are queued.
|
||||
fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> {
|
||||
if acc.seq != self.seq + 1 {
|
||||
// The host's sequence number is untrusted: at `u64::MAX` it can't step past it.
|
||||
let Some(next) = self.seq.checked_add(1) else {
|
||||
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
|
||||
};
|
||||
if acc.seq != next {
|
||||
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
|
||||
}
|
||||
if acc.author != self.id {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! A client's handling of the host's messages: sequence numbers and batch ids come from the host,
|
||||
//! so they are treated as hostile input (they must never overflow).
|
||||
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
use crate::message::{Accepted, ClientMsg, Hello, ServerMsg};
|
||||
use crate::sim_tests::base;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn hello() -> Hello {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
h
|
||||
}
|
||||
|
||||
/// A welcome whose sequence numbers are what a lying host would send.
|
||||
fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg {
|
||||
let mut s = Server::new(base(), "tok", Limits::default());
|
||||
let (_, out) = s.join(hello()).unwrap();
|
||||
let mut welcome = out
|
||||
.into_iter()
|
||||
.find_map(|o| match o {
|
||||
Outgoing::To(_, m) => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") };
|
||||
*seq0 = seq;
|
||||
processed.last_batch = last_batch;
|
||||
welcome
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sequence_numbers_do_not_overflow_the_client() {
|
||||
let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap();
|
||||
// This client's own first batch must not overflow its batch counter.
|
||||
let mut d = c.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert!(c.edit(&d).unwrap().is_some(), "the edit is batched");
|
||||
// A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow.
|
||||
let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() };
|
||||
assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err());
|
||||
}
|
||||
@@ -123,6 +123,8 @@ impl Recompute for NoRecompute {
|
||||
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod client_tests;
|
||||
#[cfg(test)]
|
||||
mod codec_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -903,15 +903,22 @@ impl Document {
|
||||
/// A unique name like "Frame 3" for a new node of a kind.
|
||||
pub fn next_name(&self, base: &str) -> String {
|
||||
let mut max = 0u64;
|
||||
let mut taken = std::collections::BTreeSet::new();
|
||||
for n in self.nodes() {
|
||||
if let Some(rest) = n.name.strip_prefix(base)
|
||||
&& let Some(num) = rest.strip_prefix(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
{
|
||||
max = max.max(k);
|
||||
taken.insert(k);
|
||||
}
|
||||
}
|
||||
format!("{base} {}", max + 1)
|
||||
let next = match max.checked_add(1) {
|
||||
Some(next) => next,
|
||||
// The counter is spent: the lowest free number instead of wrapping onto a name.
|
||||
None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1),
|
||||
};
|
||||
format!("{base} {next}")
|
||||
}
|
||||
|
||||
pub fn style(&self, id: &str) -> Option<&Style> {
|
||||
|
||||
@@ -335,6 +335,16 @@ fn next_name_increments() {
|
||||
assert_eq!(d.next_name("Ellipse"), "Ellipse 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_name_never_overflows() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX");
|
||||
d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_easings() {
|
||||
use crate::proto::Easing;
|
||||
|
||||
@@ -61,3 +61,25 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state()
|
||||
assert_eq!(s.journal, before_journal);
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_redo_steps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(s.undo_depth().1, 1);
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch");
|
||||
assert!(s.redo().unwrap());
|
||||
}
|
||||
|
||||
@@ -145,21 +145,28 @@ pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether aligning `ids` would move anything: a layer alone on the page has nothing to align to.
|
||||
pub fn can_align(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
align_target(doc, &roots(doc, ids)).is_some()
|
||||
}
|
||||
|
||||
/// What `roots` align to: one layer to its parent frame, several to their combined bounds.
|
||||
fn align_target(doc: &Document, roots: &[NodeId]) -> Option<Rect> {
|
||||
match roots {
|
||||
[] => None,
|
||||
[id] => {
|
||||
let parent = doc.node(*id)?.parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page))?;
|
||||
Some(doc.world_bounds(parent).unwrap_or_default())
|
||||
}
|
||||
_ => Some(doc.bounds_of(roots).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value, a: Al) -> Result<Value> {
|
||||
let ids = req_targets("align", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
// One layer aligns within its parent frame; several align to their combined bounds.
|
||||
let target: Rect = if ids.len() == 1 {
|
||||
let Some(id) = ids.first() else { return ok() };
|
||||
let parent = doc.get(*id)?.parent;
|
||||
match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) {
|
||||
Some(pp) => doc.world_bounds(pp).unwrap_or_default(),
|
||||
None => return ok(),
|
||||
}
|
||||
} else {
|
||||
doc.bounds_of(&ids).unwrap_or_default()
|
||||
};
|
||||
let Some(target) = align_target(doc, &ids) else { return ok() };
|
||||
for i in ids {
|
||||
let Some(b) = doc.world_bounds(i) else { continue };
|
||||
let (dx, dy) = match a {
|
||||
@@ -325,8 +332,12 @@ fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if matches!(z, Z::Back) {
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
doc.reorder(*i, k)?;
|
||||
let mut next: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
|
||||
for i in &ids {
|
||||
let Some(pp) = doc.get(*i)?.parent else { continue };
|
||||
let k = next.entry(pp).or_insert(0);
|
||||
doc.reorder(*i, *k)?;
|
||||
*k += 1;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
|
||||
@@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = ds.doc_mut();
|
||||
let nid = doc.alloc_id();
|
||||
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
|
||||
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) {
|
||||
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) {
|
||||
node.fills = fills;
|
||||
}
|
||||
node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
@@ -253,7 +253,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_num("effect.set", p, "index")? as usize;
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
@@ -302,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
let i = req_num(cmd, p, "index")? as usize;
|
||||
let i = req_index(cmd, p, "index")?;
|
||||
super::file::export_setting(cmd, ExportSetting::default(), p)?;
|
||||
for id in req_targets(cmd, s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
@@ -314,7 +314,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| {
|
||||
let i = req_num("export.remove", p, "index")? as usize;
|
||||
let i = req_index("export.remove", p, "index")?;
|
||||
for id in req_targets("export.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.exports.len() {
|
||||
@@ -412,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"deleted": ids}))
|
||||
}
|
||||
|
||||
/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the
|
||||
/// copies are the selection, Duplicate places the next copies as far from them as they are from
|
||||
/// their sources, measured then: so moving the copies first sets the step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Duplicated {
|
||||
pub sources: Vec<NodeId>,
|
||||
pub copies: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Duplicated {
|
||||
pub fn is_selection(&self, sel: &[NodeId]) -> bool {
|
||||
let set = |v: &[NodeId]| v.iter().copied().collect::<BTreeSet<_>>();
|
||||
!self.copies.is_empty() && set(&self.copies) == set(sel)
|
||||
}
|
||||
|
||||
/// The page offset from the sources to the copies, if both are still there and the copies move
|
||||
/// freely (an auto layout places its own).
|
||||
fn offset(&self, doc: &Document) -> Option<(f64, f64)> {
|
||||
let free = self
|
||||
.copies
|
||||
.iter()
|
||||
.all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none())));
|
||||
if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?);
|
||||
let d = (b.x0 - a.x0, b.y0 - a.y0);
|
||||
(d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d)
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.duplicate", s, p)?;
|
||||
let rename = s.prefs.rename_duplicates;
|
||||
let explicit = num(p, "dx").is_some() || num(p, "dy").is_some();
|
||||
let ds = s.ds()?;
|
||||
let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids));
|
||||
let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc));
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let ids = roots_now;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
for i in ids.iter().copied() {
|
||||
let n = doc.get(i)?;
|
||||
let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?;
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
@@ -432,22 +467,30 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ if top_frame => (w + 100.0, 0.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
match repeat {
|
||||
Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?,
|
||||
None => {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn bump_name(n: &str) -> String {
|
||||
if let Some((base, num)) = n.rsplit_once(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
&& let Some(next) = k.checked_add(1)
|
||||
{
|
||||
return format!("{base} {}", k + 1);
|
||||
return format!("{base} {next}");
|
||||
}
|
||||
format!("{n} 2")
|
||||
}
|
||||
@@ -803,7 +846,8 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
let mut n = 0u32;
|
||||
let mut count = 0;
|
||||
for c in digits.chars() {
|
||||
n = n * 10 + c.to_digit(10)?;
|
||||
// Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps.
|
||||
n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?;
|
||||
count += 1;
|
||||
}
|
||||
match count {
|
||||
@@ -970,13 +1014,20 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
// Replace occurrence by occurrence so styles shift correctly.
|
||||
// The search resumes after each inserted replacement, so a replacement that
|
||||
// contains `find` neither loops forever nor stops the other occurrences.
|
||||
let mut guard = 0;
|
||||
while let Some(pos) = t.characters.find(find.as_str()) {
|
||||
let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0);
|
||||
let len = find.chars().count();
|
||||
let mut cursor = 0;
|
||||
let len = find.chars().count();
|
||||
let r_len = r.chars().count();
|
||||
loop {
|
||||
let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b);
|
||||
let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break };
|
||||
let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count());
|
||||
t.replace_range(start, start + len, r);
|
||||
cursor = start + r_len;
|
||||
guard += 1;
|
||||
if guard > 10_000 || r.contains(find.as_str()) {
|
||||
if guard > 10_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
|
||||
let i = req_num("doc.switch", p, "index")? as usize;
|
||||
let i = req_index("doc.switch", p, "index")?;
|
||||
if i >= s.docs.len() {
|
||||
return Err(bad("doc.switch", "no such document"));
|
||||
}
|
||||
@@ -437,15 +437,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
read(path)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
|
||||
/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never
|
||||
/// truncates `path`, and no file or symlink at a staging name is ever written through.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
let tmp = format!("{path}.tmp-figcraft");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
// Leave nothing behind when the file can't take its place.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})
|
||||
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -1327,3 +1324,126 @@ mod rest_import_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod staging_write_tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges).
|
||||
#[cfg(unix)]
|
||||
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
|
||||
std::os::unix::fs::symlink(to, from).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
|
||||
std::os::windows::fs::symlink_file(to, from).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_staging_file_is_never_written_through() {
|
||||
let d = dir("existing");
|
||||
let path = d.join("shot.svg");
|
||||
// The old deterministic staging name, held by an unrelated file: the write must pick its
|
||||
// own staging name instead of truncating this one.
|
||||
let old = d.join("shot.svg.tmp-figcraft");
|
||||
std::fs::write(&old, b"not yours").unwrap();
|
||||
write(&path.to_string_lossy(), b"<svg/>").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
|
||||
assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched");
|
||||
// Just the file and the untouched staging file: no new staging leftovers.
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_staging_symlink_is_never_followed() {
|
||||
let d = dir("symlink");
|
||||
let path = d.join("shot.svg");
|
||||
let victim = d.join("victim.txt");
|
||||
std::fs::write(&victim, b"innocent").unwrap();
|
||||
let old = d.join("shot.svg.tmp-figcraft");
|
||||
if !symlink(&old, &victim) {
|
||||
return; // Making a symlink needs privileges on some Windows setups.
|
||||
}
|
||||
write(&path.to_string_lossy(), b"<svg/>").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_writers_of_one_file_never_interleave() {
|
||||
let d = dir("concurrent");
|
||||
let path = d.join("shot.svg");
|
||||
for round in 0..5u8 {
|
||||
let mut handles = Vec::new();
|
||||
for w in 0u8..4 {
|
||||
let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]);
|
||||
handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes)));
|
||||
}
|
||||
let mut wins = 0;
|
||||
for h in handles {
|
||||
// On Windows a rename over a file another writer just replaced can lose the race.
|
||||
if h.join().unwrap().is_ok() {
|
||||
wins += 1;
|
||||
}
|
||||
}
|
||||
assert!(wins > 0, "round {round}: every writer lost");
|
||||
// Whatever landed is one writer's complete payload, never a mix.
|
||||
let got = std::fs::read(&path).unwrap();
|
||||
assert_eq!(got.len(), 8192, "round {round}: a complete payload landed");
|
||||
assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix");
|
||||
}
|
||||
// No staging file was left stuck (a failed writer removes its own).
|
||||
let left = std::fs::read_dir(&d).unwrap().count();
|
||||
assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
|
||||
let d = dir("failure");
|
||||
// The destination is a directory: the rename fails, our staging file is removed and the
|
||||
// directory is untouched.
|
||||
let taken = d.join("over-a-directory");
|
||||
std::fs::create_dir_all(&taken).unwrap();
|
||||
assert!(write(&taken.to_string_lossy(), b"payload").is_err());
|
||||
assert!(taken.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind");
|
||||
// A missing parent directory fails without creating anything anywhere.
|
||||
assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err());
|
||||
assert!(!d.join("no").exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// A read-only directory refuses the staging file, and the old destination stays.
|
||||
let ro = d.join("read-only");
|
||||
std::fs::create_dir_all(&ro).unwrap();
|
||||
let dest = ro.join("keep.svg");
|
||||
std::fs::write(&dest, b"keep").unwrap();
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_mode(0o500);
|
||||
std::fs::set_permissions(&ro, perms).unwrap();
|
||||
// Running as root would defeat the read-only directory: skip instead of failing.
|
||||
let probe = ro.join(".probe");
|
||||
if std::fs::write(&probe, b"").is_ok() {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
} else {
|
||||
assert!(write(&dest.to_string_lossy(), b"payload").is_err());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"keep");
|
||||
assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind");
|
||||
}
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(&ro, perms).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
let background = s.new_page_background();
|
||||
// An added page takes the current page's colour; the theme only decides new files.
|
||||
let current = s.page().ok().and_then(|p| match s.doc().ok()?.node(p)?.kind {
|
||||
NodeKind::Page { background, .. } => Some(background),
|
||||
_ => None,
|
||||
});
|
||||
let background = current.unwrap_or_else(|| s.new_page_background());
|
||||
let doc = s.doc_mut()?;
|
||||
let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1));
|
||||
let id = doc.add_page(&name);
|
||||
@@ -47,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = match id(p, "id") {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let k = req_num("page.select", p, "index")? as usize;
|
||||
let k = req_index("page.select", p, "index")?;
|
||||
*doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
|
||||
@@ -362,9 +362,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get(i)?.clone();
|
||||
if !n.has_visible_stroke() {
|
||||
continue;
|
||||
@@ -409,7 +409,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
return Ok(json!({"ids": out}));
|
||||
continue;
|
||||
}
|
||||
// Standing in for the layer, the outline keeps how it is drawn and placed.
|
||||
(v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect);
|
||||
|
||||
@@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool {
|
||||
|
||||
/// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected.
|
||||
fn scene() -> (Session, NodeId, NodeId) {
|
||||
// The text is measured with a font: without craft-fonts that is a system one, scanned here
|
||||
// rather than by whichever test happens to run first (#333).
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::new();
|
||||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})));
|
||||
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}})));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Text made by commands in the app (the interface reported its theme) is readable on its page
|
||||
//! like text drawn with the Text tool: white on a dark page unless a fill is named. A session
|
||||
//! without an interface (CLI, MCP, exports) keeps the model's black everywhere.
|
||||
//! Text made by commands in the app (the interface reported its theme) is readable where it is
|
||||
//! drawn, like text drawn with the Text tool: white on a dark page or a dark frame unless a fill is
|
||||
//! named. A session without an interface (CLI, MCP, exports) keeps the model's black everywhere.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::NodeId;
|
||||
@@ -62,12 +62,51 @@ fn text_made_by_commands_is_readable_on_its_page() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_frame_follows_the_page_it_is_on() {
|
||||
// As with the Text tool: the page decides, also inside a frame; made in one undo step.
|
||||
fn text_in_a_frame_is_readable_on_the_frame() {
|
||||
// #362: what the text is drawn on decides: the nearest layer around it with a solid fill (a
|
||||
// new frame's white too), else its page; made in one undo step.
|
||||
let (white, black) = (vec![Paint::solid(Color::WHITE)], vec![Paint::solid(Color::BLACK)]);
|
||||
let mut s = session(Some(true));
|
||||
let frame: NodeId = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap();
|
||||
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": frame}));
|
||||
assert_eq!(fills(&s, &t), vec![Paint::solid(Color::WHITE)]);
|
||||
let frame = |s: &mut Session, x: i64, parent: Option<NodeId>, fills: Option<Value>| -> NodeId {
|
||||
let mut p = json!({"x": x, "y": 0, "width": 200, "height": 100});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
let f = id_of(&ex(s, "frame.create", p)["id"]).unwrap();
|
||||
if let Some(fills) = fills {
|
||||
ex(s, "node.set", json!({"id": f, "props": {"fills": fills}}));
|
||||
}
|
||||
f
|
||||
};
|
||||
let light = frame(&mut s, 0, None, None);
|
||||
let dark = frame(&mut s, 300, None, Some(json!([{"type": "solid", "color": "#202020"}])));
|
||||
let bare = frame(&mut s, 600, None, Some(json!([])));
|
||||
let inner = frame(&mut s, 0, Some(light), Some(json!([])));
|
||||
let faint = frame(&mut s, 900, None, Some(json!([{"type": "solid", "color": "#FFFFFF", "opacity": 0.2}])));
|
||||
let stacked = frame(&mut s, 1200, None, Some(json!([{"type": "solid", "color": "#202020"}, {"type": "solid", "color": "#FFFFFF"}])));
|
||||
let cases = [
|
||||
(light, &black, "a white frame on a dark page: black text"),
|
||||
(dark, &white, "a dark frame: white"),
|
||||
(bare, &white, "a frame without a fill: the page decides"),
|
||||
(inner, &black, "a frame without a fill in a white one: the white one"),
|
||||
(faint, &white, "a faint fill lets the page show through: the page decides"),
|
||||
(stacked, &black, "the fill on top counts"),
|
||||
];
|
||||
for (parent, want, why) in cases {
|
||||
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": parent}));
|
||||
assert_eq!(&fills(&s, &t), want, "{why}");
|
||||
}
|
||||
// The Text tool reads it the same way.
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let at = s.doc().unwrap().world_bounds(light).unwrap().center();
|
||||
s.set_tool(ToolId::Text);
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, at.x, at.y, 1.0)).unwrap();
|
||||
}
|
||||
let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
assert_eq!(s.doc().unwrap().node(typed).unwrap().fills, black, "the Text tool in a white frame");
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": light}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(id_of(&t["id"]).unwrap()).is_none());
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
|
||||
/// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text).
|
||||
fn scene(s: &mut Session) -> (NodeId, NodeId) {
|
||||
// The text is measured with a font: without craft-fonts that is a system one, scanned here
|
||||
// rather than by whichever test happens to run first (#333).
|
||||
figcraft_text::scan_system_fonts();
|
||||
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap();
|
||||
let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap();
|
||||
let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges,
|
||||
//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies
|
||||
//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||||
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards).
|
||||
fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..PointerEvent::at(kind, x, y, 1.0) };
|
||||
s.pointer(&ev(PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, to)).unwrap();
|
||||
let sel = s.selection();
|
||||
assert_eq!(sel.len(), 1, "one copy selected");
|
||||
sel[0]
|
||||
}
|
||||
|
||||
/// Duplicate the selection: the copy.
|
||||
fn cmd_d(s: &mut Session) -> NodeId {
|
||||
let out = ex(s, "edit.duplicate", json!({}));
|
||||
id_of(&out["ids"][0]).unwrap()
|
||||
}
|
||||
|
||||
fn at(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
// 2 px below the original's top edge: within the snap distance.
|
||||
let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0));
|
||||
assert_ne!(copy, a);
|
||||
assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0);
|
||||
rect(&mut s, 200.0, 0.0);
|
||||
// B's copy dropped 2 px short of the 100 gap that A and B have.
|
||||
let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0));
|
||||
assert_eq!(at(&s, copy), (400.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_moving_the_copy() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}}));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
|
||||
let c3 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_after_an_alt_drag_repeats_its_step() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0);
|
||||
let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0));
|
||||
assert_eq!(at(&s, copy), (130.0, 0.0));
|
||||
let next = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, next), (260.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_only_repeats_for_the_last_copies() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
// Another selection forgets it: the original duplicates in place.
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
ex(&mut s, "select.set", json!({"ids": [c1]}));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed");
|
||||
// An offset given explicitly wins over a remembered one.
|
||||
ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}}));
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"dx": 10}));
|
||||
let c3 = id_of(&out["ids"][0]).unwrap();
|
||||
assert_eq!(at(&s, c3), (310.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_doesnt_let_a_new_layer_take_a_remembered_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().contains(c1));
|
||||
// Undo gives ids back: the new rectangle may well get the undone copy's.
|
||||
let r = rect(&mut s, 500.0, 500.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let c = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_step_is_measured_in_page_space_inside_a_rotated_frame() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}}));
|
||||
let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap();
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
let (p0, p1) = (at(&s, a), at(&s, c1));
|
||||
let c2 = cmd_d(&mut s);
|
||||
let p2 = at(&s, c2);
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
|
||||
for i in 0..2 {
|
||||
ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f}));
|
||||
}
|
||||
ex(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let kids = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
let b = s.doc().unwrap().world_bounds(kids[0]).unwrap();
|
||||
let from = (b.center().x, b.center().y);
|
||||
alt_drag(&mut s, from, (from.0 + 70.0, from.1));
|
||||
assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat");
|
||||
let n = s.doc().unwrap().node(f).unwrap().children.len();
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
@@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() {
|
||||
let css = code_for(doc, r, CodeLang::Css);
|
||||
assert!(css.contains("backdrop-filter: blur(2px);"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_or_fractional_index_is_refused_not_item_zero() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
let before = s.doc().unwrap().node(r).unwrap().clone();
|
||||
assert_eq!(before.exports.len(), 2);
|
||||
for index in [json!(-1), json!(-0.5), json!(1.5)] {
|
||||
let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}});
|
||||
assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}");
|
||||
let upd = json!({"ids": [r], "index": index, "format": "svg"});
|
||||
assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}");
|
||||
assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}");
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.effects, before.effects, "index {index}");
|
||||
assert_eq!(n.exports, before.exports, "index {index}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works
|
||||
//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control
|
||||
//! channel send params straight to `Session::execute`.
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::{id_of, paint_of};
|
||||
|
||||
/// The red→blue left-to-right gradient as written in the docs.
|
||||
fn short() -> Value {
|
||||
json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]})
|
||||
}
|
||||
|
||||
/// The same gradient in the engine's own form.
|
||||
fn long() -> Value {
|
||||
json!({"type": "gradient", "kind": "linear",
|
||||
"stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}},
|
||||
{"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}],
|
||||
"from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]})
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
s.doc().unwrap().node(id).unwrap().fills.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shorthand_reads_as_the_long_form() {
|
||||
assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_takes_the_shorthand() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap();
|
||||
s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap();
|
||||
let want = paint_of(&long()).unwrap();
|
||||
assert_eq!(fills(&s, r), vec![want.clone()]);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_create_fill_add_and_fill_set_take_the_shorthand() {
|
||||
let want = paint_of(&long()).unwrap();
|
||||
let mut s = Session::new();
|
||||
let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap();
|
||||
let r = id_of(&v["id"]).unwrap();
|
||||
assert_eq!(fills(&s, r), vec![want.clone()]);
|
||||
s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap();
|
||||
assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]);
|
||||
let r2 = rect(&mut s);
|
||||
s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap();
|
||||
assert_eq!(fills(&s, r2), vec![want]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_defaults_match_the_mcp_server() {
|
||||
// No handles: top to bottom, with a square width handle.
|
||||
let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap();
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(
|
||||
(v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()),
|
||||
(json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0]))
|
||||
);
|
||||
// Plain colour stops are spread evenly.
|
||||
let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap();
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v["kind"], "radial");
|
||||
let pos: Vec<f64> = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect();
|
||||
assert_eq!(pos, vec![0.0, 0.5, 1.0]);
|
||||
for kind in ["angular", "diamond"] {
|
||||
let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind);
|
||||
}
|
||||
// A CSS colour inside a solid paint or a positioned stop is read too.
|
||||
assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00")));
|
||||
let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap();
|
||||
assert_eq!(p, paint_of(&long()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_is_still_refused() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
for junk in [
|
||||
json!({"type": "linearGradient", "stops": "red"}),
|
||||
json!({"type": "linearGradient", "stops": [42]}),
|
||||
json!({"type": "fooGradient", "stops": ["#ff0000"]}),
|
||||
json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}),
|
||||
] {
|
||||
let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string();
|
||||
assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}");
|
||||
}
|
||||
// Deep nesting doesn't overflow the stack.
|
||||
let mut deep = json!("#ff0000");
|
||||
for _ in 0..200 {
|
||||
deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]});
|
||||
}
|
||||
assert!(paint_of(&deep).is_none());
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing
|
||||
//! behind — also during an interaction, whose last valid preview and own snapshot survive it.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn rect(s: &mut Session) -> crate::NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// `width` applies (geometry keys go first), then `visible` fails validation.
|
||||
fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result<serde_json::Value> {
|
||||
s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_command_during_an_interaction_keeps_the_prior_preview() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.begin_interaction("Move").unwrap();
|
||||
let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]);
|
||||
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
|
||||
let (previewed, selection, page) = {
|
||||
let ds = s.ds().unwrap();
|
||||
(ds.doc.clone(), ds.selection.clone(), ds.page)
|
||||
};
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
assert!(partially_applicable(&mut s, r).is_err());
|
||||
|
||||
// The rejected edit is gone: the command-entry state (the valid preview) is back.
|
||||
let ds = s.ds().unwrap();
|
||||
assert_eq!(*ds.doc, *previewed, "the document is what the preview showed");
|
||||
assert_eq!(ds.doc.node(r).unwrap().width, 80.0);
|
||||
assert_eq!(ds.selection, selection);
|
||||
assert_eq!(ds.page, page);
|
||||
assert_eq!(ds.history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
|
||||
// The interaction is alive and commits the preview, not the rejected mutation.
|
||||
s.commit_interaction().unwrap();
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!((n.width, n.visible), (80.0, true));
|
||||
assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed");
|
||||
assert_eq!(s.undo_depth(), (before_history + 1, 0));
|
||||
assert_eq!(s.undo_labels(1), vec!["Move".to_string()]);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_command_without_an_interaction_still_rolls_back() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
assert!(partially_applicable(&mut s, r).is_err());
|
||||
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!((n.width, n.visible), (80.0, true));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
}
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::edit::opacity_from_digits;
|
||||
use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary};
|
||||
use crate::params::id_of;
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other];
|
||||
@@ -186,6 +186,7 @@ fn opacity_digits() {
|
||||
assert_eq!(opacity_from_digits("00"), Some(0.0));
|
||||
assert_eq!(opacity_from_digits(""), None);
|
||||
assert_eq!(opacity_from_digits("123"), None);
|
||||
assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32");
|
||||
assert_eq!(opacity_from_digits("4x"), None);
|
||||
assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only");
|
||||
|
||||
@@ -201,6 +202,10 @@ fn opacity_digits() {
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(op(&s, a), 0.3, "one undo step");
|
||||
assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err());
|
||||
assert!(
|
||||
matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })),
|
||||
"an overflowing digit string is a parameter error, not a panic"
|
||||
);
|
||||
assert!(s.execute("object.opacityKeys", &json!({})).is_err());
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected");
|
||||
|
||||
@@ -328,6 +328,9 @@ pub struct DocState {
|
||||
pub guide: Option<(NodeId, usize)>,
|
||||
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
|
||||
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
|
||||
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
|
||||
/// [`cmd::edit::Duplicated`]; not saved).
|
||||
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
|
||||
}
|
||||
|
||||
/// A version shown on the canvas in place of the document: read-only until it's restored or the
|
||||
@@ -383,6 +386,7 @@ impl DocState {
|
||||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
last_duplicate: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
@@ -538,6 +542,10 @@ impl DocState {
|
||||
if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) {
|
||||
self.rotation_origin = None;
|
||||
}
|
||||
// So does the last duplicate (undo reuses ids: a new layer could take a copy's).
|
||||
if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) {
|
||||
self.last_duplicate = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,6 +560,7 @@ pub struct Prefs {
|
||||
pub highlight_on_hover: bool,
|
||||
pub number_keys_opacity: bool,
|
||||
pub show_dimensions: bool,
|
||||
/// "system" by default; an explicit "light" or "dark" choice stays saved.
|
||||
pub theme: String,
|
||||
/// The widths of the main window's left and right panels (the interface keeps them within its
|
||||
/// limits; other windows have their own while they are open).
|
||||
@@ -611,7 +620,7 @@ impl Default for Prefs {
|
||||
highlight_on_hover: true,
|
||||
number_keys_opacity: true,
|
||||
show_dimensions: true,
|
||||
theme: "light".into(),
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
@@ -663,8 +672,8 @@ pub struct Session {
|
||||
pub prefs: Prefs,
|
||||
pub tool: tools::ToolState,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages
|
||||
/// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no
|
||||
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the
|
||||
/// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no
|
||||
/// theme, so what scripts make is the same everywhere.
|
||||
pub ui_dark: Option<bool>,
|
||||
/// Command log (id + params) for actions/automation.
|
||||
@@ -721,7 +730,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The background new pages get: the dark page colour while the interface is dark.
|
||||
/// The background a new file's page gets: the dark page colour while the interface is dark.
|
||||
pub fn new_page_background(&self) -> figcraft_color::Color {
|
||||
if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE }
|
||||
}
|
||||
@@ -837,10 +846,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Roll back any partial edit.
|
||||
// Roll back any partial edit — also during an interaction, where `before` is the
|
||||
// interaction's last valid preview: restore it and leave the gesture itself (its
|
||||
// own snapshot) alone.
|
||||
if let Some((doc, sel, page, uid)) = before
|
||||
&& let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& ds.interaction.is_none()
|
||||
{
|
||||
ds.doc = doc;
|
||||
ds.selection = sel;
|
||||
@@ -1082,6 +1092,8 @@ impl Session {
|
||||
self.ds()?;
|
||||
let before: Vec<_> =
|
||||
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
|
||||
// An edit inside clears the redo steps; a failed group has to bring them back.
|
||||
let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect();
|
||||
let active = self.active;
|
||||
let journal_len = self.journal.len();
|
||||
|
||||
@@ -1099,8 +1111,9 @@ impl Session {
|
||||
|
||||
if result.is_err() {
|
||||
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
|
||||
for (uid, doc, selection, page, undo_len, limit) in &before {
|
||||
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||
ds.history.redo = redo;
|
||||
ds.doc = doc.clone();
|
||||
ds.selection = selection.clone();
|
||||
ds.page = *page;
|
||||
@@ -1441,6 +1454,8 @@ mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_spacing_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod export_behaviour_tests;
|
||||
@@ -1467,6 +1482,8 @@ mod fx_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod gesture_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod gradient_shorthand_tests;
|
||||
#[cfg(test)]
|
||||
mod hide_publishing_tests;
|
||||
#[cfg(test)]
|
||||
mod history_behaviour_tests;
|
||||
@@ -1477,6 +1494,8 @@ mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
@@ -1517,6 +1536,8 @@ mod panels_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
|
||||
@@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() {
|
||||
e
|
||||
};
|
||||
s.pointer(&ev(PointerKind::Down, 30.0)).unwrap();
|
||||
// Alt duplicates: the copy moves to 110..160, between the original and the other layer.
|
||||
// Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original
|
||||
// and the other layer (105..155).
|
||||
s.pointer(&ev(PointerKind::Move, 130.0)).unwrap();
|
||||
let g = s.tool.overlay.guides.clone();
|
||||
assert_eq!(
|
||||
labelled(&g),
|
||||
vec![
|
||||
("30".into(), [135, 0, 135, 30]),
|
||||
("30".into(), [135, 70, 135, 100]),
|
||||
("40".into(), [160, 50, 200, 50]),
|
||||
("50".into(), [60, 50, 110, 50])
|
||||
("30".into(), [130, 0, 130, 30]),
|
||||
("30".into(), [130, 70, 130, 100]),
|
||||
("45".into(), [60, 50, 105, 50]),
|
||||
("45".into(), [155, 50, 200, 50])
|
||||
]
|
||||
);
|
||||
s.pointer(&ev(PointerKind::Up, 130.0)).unwrap();
|
||||
|
||||
@@ -337,6 +337,23 @@ fn pages_by_the_documented_rules() {
|
||||
assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
let p2 = id(&ex(&mut s, "page.add", json!({})));
|
||||
for bad_index in [json!(-1), json!(0.5)] {
|
||||
assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail");
|
||||
assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone");
|
||||
}
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
let active = s.active;
|
||||
assert_eq!(active, 1);
|
||||
for bad_index in [json!(-1), json!(0.5)] {
|
||||
assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail");
|
||||
assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
|
||||
let mut s = Session::new();
|
||||
@@ -386,3 +403,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
|
||||
assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped");
|
||||
assert_eq!(s.doc().unwrap().page_of(into), Some(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "pref.renameDuplicates", json!({"on": true}));
|
||||
let r = rect(&mut s, "r", 30.0, 40.0, None);
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}}));
|
||||
let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap();
|
||||
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use figcraft_color::{BlendMode, Color, Paint};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session, bad};
|
||||
|
||||
@@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option<Color> {
|
||||
get(p, key).and_then(color_of)
|
||||
}
|
||||
|
||||
/// A paint from JSON: a colour string/object (solid) or a full paint object.
|
||||
/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own
|
||||
/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a},
|
||||
/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default
|
||||
/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours
|
||||
/// are spread evenly. Anything else is left for serde to refuse.
|
||||
pub fn normalize_paint(v: &mut Value) {
|
||||
normalize_paintish(v, 0);
|
||||
}
|
||||
|
||||
fn normalize_paintish(v: &mut Value, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => {
|
||||
for x in a {
|
||||
normalize_paintish(x, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) {
|
||||
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
|
||||
}
|
||||
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
|
||||
if is_gradient {
|
||||
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
|
||||
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
|
||||
&& t != "gradient"
|
||||
{
|
||||
let kind = t.trim_end_matches("gradient").to_string();
|
||||
o.insert("type".into(), json!("gradient"));
|
||||
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
|
||||
}
|
||||
o.entry("kind").or_insert(json!("linear"));
|
||||
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
|
||||
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
|
||||
o.entry("from").or_insert(json!(from));
|
||||
o.entry("to").or_insert(json!(to));
|
||||
// A linear gradient's width handle goes square to its axis (else its bands skew,
|
||||
// or vanish when the handle lies on the axis).
|
||||
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
if linear
|
||||
&& !o.contains_key("width")
|
||||
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
|
||||
{
|
||||
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
|
||||
}
|
||||
o.entry("width").or_insert(json!(width));
|
||||
if let Some(Value::Array(stops)) = o.get_mut("stops") {
|
||||
let n = stops.len();
|
||||
for (i, s) in stops.iter_mut().enumerate() {
|
||||
if let Some(c) = color_of(s).filter(|_| s.is_string()) {
|
||||
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
*s = json!({"position": pos, "color": c});
|
||||
} else {
|
||||
normalize_paintish(s, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand
|
||||
/// ([`normalize_paint`]).
|
||||
pub fn paint_of(v: &Value) -> Option<Paint> {
|
||||
if let Some(c) = color_of(v) {
|
||||
return Some(Paint::solid(c));
|
||||
}
|
||||
let mut p: Paint = serde_json::from_value(v.clone()).ok()?;
|
||||
let mut v = v.clone();
|
||||
normalize_paint(&mut v);
|
||||
let mut p: Paint = serde_json::from_value(v).ok()?;
|
||||
p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 };
|
||||
Some(p)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments
|
||||
//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle
|
||||
/// (to - p).
|
||||
fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) {
|
||||
at(s, PointerKind::Down, x, y);
|
||||
at(s, PointerKind::Move, to_x, to_y);
|
||||
at(s, PointerKind::Up, to_x, to_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
|
||||
assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point");
|
||||
// The second point, mid-drag.
|
||||
at(&mut s, PointerKind::Down, 200.0, 200.0);
|
||||
assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band");
|
||||
at(&mut s, PointerKind::Move, 200.0, 240.0);
|
||||
assert_eq!(
|
||||
s.tool.overlay.pen_handles,
|
||||
vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]],
|
||||
"the one before's out handle, then the dragged point's in and out"
|
||||
);
|
||||
at(&mut s, PointerKind::Up, 200.0, 240.0);
|
||||
// Moving on: the band leaves along the last out handle.
|
||||
at(&mut s, PointerKind::Hover, 300.0, 200.0);
|
||||
assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0]));
|
||||
assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_points_have_no_handles_and_a_straight_band() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
for (x, y) in [(100.0, 100.0), (200.0, 100.0)] {
|
||||
at(&mut s, PointerKind::Down, x, y);
|
||||
at(&mut s, PointerKind::Up, x, y);
|
||||
}
|
||||
at(&mut s, PointerKind::Hover, 250.0, 200.0);
|
||||
assert!(s.tool.overlay.pen_handles.is_empty());
|
||||
assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0]));
|
||||
assert_eq!(s.tool.overlay.rubber_handle, None, "straight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_the_path_clears_its_handles_and_band() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
|
||||
drag_point(&mut s, 200.0, 200.0, 200.0, 240.0);
|
||||
at(&mut s, PointerKind::Hover, 300.0, 200.0);
|
||||
s.pen_finish().unwrap();
|
||||
let ov = &s.tool.overlay;
|
||||
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
|
||||
}
|
||||
@@ -431,3 +431,31 @@ fn flatten_and_outline_stroke() {
|
||||
let (path, _) = figcraft_render::node_outline(s.doc().unwrap(), &n).unwrap();
|
||||
assert!((path.bounding_box().height() - 10.0).abs() < 1e-6, "the outline is as thick as the stroke was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_outlines_every_selected_layer() {
|
||||
// Fixed (#378): with several filled, stroked shapes selected, only the first was outlined.
|
||||
for by_selection in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let a = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
let b = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}));
|
||||
let line = create(&mut s, "shape.line", json!({"x": 0, "y": 100, "width": 100, "props": {"strokeWeight": 4}}));
|
||||
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 4}}));
|
||||
let out = if by_selection {
|
||||
s.set_selection(vec![a, b, line]);
|
||||
ex(&mut s, "vector.outlineStroke", json!({}))
|
||||
} else {
|
||||
ex(&mut s, "vector.outlineStroke", json!({"ids": [a, b, line]}))
|
||||
};
|
||||
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
|
||||
assert_eq!(ids.len(), 3, "one result per layer: {out}");
|
||||
assert!(matches!(node(&s, ids[0]).kind, NodeKind::Group), "filled shape a becomes a group");
|
||||
assert!(matches!(node(&s, ids[1]).kind, NodeKind::Group), "filled shape b becomes a group");
|
||||
assert!(matches!(node(&s, ids[2]).kind, NodeKind::Vector { .. }), "the line becomes a vector");
|
||||
for id in [a, b] {
|
||||
assert!(node(&s, id).strokes.is_empty(), "{id:?} still has its stroke");
|
||||
}
|
||||
assert!(s.doc().unwrap().node(line).is_none(), "the line was replaced by its outline");
|
||||
assert_eq!(s.selection(), ids, "the results are selected");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -74,15 +74,6 @@ impl Targets {
|
||||
// A layer's box in snap space.
|
||||
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
|
||||
let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() };
|
||||
let push = |r: Rect, t: &mut Targets| {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
t.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
t.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
t.boxes.push(r);
|
||||
};
|
||||
let parent = parent_of(doc, page, moving);
|
||||
// The parent frame's own box (and its layout grids).
|
||||
if parent != page
|
||||
@@ -109,7 +100,7 @@ impl Targets {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = bbox(*c) {
|
||||
push(b, &mut t);
|
||||
t.add_box(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +127,17 @@ impl Targets {
|
||||
t
|
||||
}
|
||||
|
||||
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
|
||||
pub fn add_box(&mut self, r: Rect) {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
self.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
self.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
self.boxes.push(r);
|
||||
}
|
||||
|
||||
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
|
||||
/// and a marker when a moving point lands on it.
|
||||
pub fn add_point(&mut self, p: Point) {
|
||||
|
||||
@@ -161,6 +161,28 @@ fn text_create_autosize_and_edit() {
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_replaces_every_occurrence_when_replacement_contains_find() {
|
||||
for (text, find, replace, want) in [
|
||||
("cat and cat", "cat", "cats", "cats and cats"),
|
||||
("cat and cat", "cat", "category", "category and category"),
|
||||
("banana", "a", "aa", "baanaanaa"),
|
||||
] {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap());
|
||||
s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_rejects_empty_find() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap());
|
||||
assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_and_instances_sync() {
|
||||
let mut s = sess();
|
||||
@@ -253,6 +275,49 @@ fn align_distribute_zorder() {
|
||||
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_alone_on_the_page_has_nothing_to_align_to() {
|
||||
use crate::cmd::arrange::can_align;
|
||||
let mut s = sess();
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
||||
let inner = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
let loose = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 5, "width": 10, "height": 10})).unwrap());
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(!can_align(doc, &[]));
|
||||
assert!(!can_align(doc, &[loose]), "alone on the page");
|
||||
assert!(!can_align(doc, &[f, inner]), "a frame with its own child is the frame alone");
|
||||
assert!(can_align(doc, &[inner]), "one in a frame aligns to it");
|
||||
assert!(can_align(doc, &[f, loose]), "two align to each other");
|
||||
// Where the buttons are off, the command still does nothing (and no harm).
|
||||
let before = doc.world_bounds(loose);
|
||||
s.execute("align.right", &json!({"ids": [loose]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(loose), before);
|
||||
s.execute("align.right", &json!({"ids": [inner]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap().x1, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_to_back_across_frames_counts_per_parent() {
|
||||
let mut s = sess();
|
||||
let make = |s: &mut Session, p: Value| id(&s.execute("node.create", &p).unwrap());
|
||||
let fa = make(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
let fb = make(&mut s, json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200}));
|
||||
let rect = |parent: NodeId| json!({"type": "rectangle", "parent": parent, "x": 10, "y": 10, "width": 20, "height": 20});
|
||||
let a: Vec<NodeId> = (0..2).map(|_| make(&mut s, rect(fa))).collect();
|
||||
let b: Vec<NodeId> = (0..3).map(|_| make(&mut s, rect(fb))).collect();
|
||||
let idx = |s: &Session, n: NodeId| s.doc().unwrap().index_in_parent(n);
|
||||
assert_eq!(idx(&s, a[1]), Some(1));
|
||||
assert_eq!(idx(&s, b[2]), Some(2));
|
||||
s.execute("arrange.back", &json!({"ids": [a[1], b[2]]})).unwrap();
|
||||
assert_eq!(idx(&s, a[1]), Some(0), "top of frame A at the back");
|
||||
assert_eq!(idx(&s, b[2]), Some(0), "top of frame B at the back");
|
||||
// Two from B plus one from A: each parent's selection ends at 0..n in its original order.
|
||||
s.execute("arrange.back", &json!({"ids": [b[1], a[0], b[0]]})).unwrap();
|
||||
let kids = |s: &Session, f: NodeId| s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
assert_eq!(kids(&s, fa), vec![a[0], a[1]]);
|
||||
assert_eq!(kids(&s, fb), vec![b[0], b[1], b[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_and_duplicate() {
|
||||
let mut s = sess();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The page colour of new files and pages follows the interface's theme when they are made (dark:
|
||||
//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface
|
||||
//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is
|
||||
//! white on a dark page and black on a light one.
|
||||
//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E,
|
||||
//! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page
|
||||
//! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the
|
||||
//! light default. Text drawn with the Text tool is white on a dark page and black on a light one.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
@@ -38,8 +38,8 @@ fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_and_pages_take_the_theme_they_are_made_in() {
|
||||
// (theme the interface reported, colour of a new file's page and of an added page)
|
||||
fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() {
|
||||
// (theme the interface reported, colour of a new file's page and of a page added to it)
|
||||
for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] {
|
||||
let mut s = Session::empty();
|
||||
if let Some(d) = dark {
|
||||
@@ -62,8 +62,10 @@ fn a_change_of_theme_leaves_existing_pages_alone() {
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
s.set_ui_dark(false);
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme");
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"}));
|
||||
s.set_ui_dark(true);
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with");
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour");
|
||||
// A copy of a page is that page's colour, whatever the theme.
|
||||
let first = s.doc().unwrap().pages[0];
|
||||
s.set_ui_dark(false);
|
||||
@@ -71,6 +73,57 @@ fn a_change_of_theme_leaves_existing_pages_alone() {
|
||||
assert_eq!(background(&s, copy), Color::DARK_PAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_page_takes_the_current_pages_colour() {
|
||||
// Whatever the theme, and headless too: the page it was added from decides.
|
||||
for dark in [None, Some(false), Some(true)] {
|
||||
let mut s = Session::empty();
|
||||
if let Some(d) = dark {
|
||||
s.set_ui_dark(d);
|
||||
}
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#336699"}));
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}");
|
||||
// From the second page, now current, after giving it its own colour.
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"}));
|
||||
ex(&mut s, "page.add", json!({"name": "Third"}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}");
|
||||
// One undo step, the colour with it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo");
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo");
|
||||
}
|
||||
// From a page other than the first: go there, then add.
|
||||
let mut s = Session::new();
|
||||
s.set_ui_dark(true);
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"}));
|
||||
ex(&mut s, "page.select", json!({"index": 0}));
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_page_colours_survive_save_and_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("pages.figcraft");
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#336699"}));
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()}));
|
||||
let mut s = Session::empty();
|
||||
s.set_ui_dark(true);
|
||||
ex(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#336699"]);
|
||||
// And a page added to the reopened file in the dark theme follows the file, not the theme.
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() {
|
||||
// Made before the interface knew its theme, nothing done to it yet.
|
||||
@@ -118,13 +171,14 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
|
||||
let t = draw_text(&mut s, 40.0, 40.0);
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}");
|
||||
}
|
||||
// In a frame on a dark page too; so is text a command makes in the app (`dark_page_text_tests`).
|
||||
// In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes
|
||||
// in the app (`dark_page_text_tests`).
|
||||
let mut s = Session::empty();
|
||||
s.set_ui_dark(true);
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
let t = draw_text(&mut s, 50.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::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)]);
|
||||
}
|
||||
|
||||
+75
-11
@@ -266,6 +266,12 @@ pub struct Overlay {
|
||||
/// Pen tool: the in-progress path's anchor points and the rubber band to the pointer.
|
||||
pub pen: Vec<[f64; 2]>,
|
||||
pub rubber: Option<[f64; 4]>,
|
||||
/// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last
|
||||
/// anchor's in and out handles and the one before's out handle.
|
||||
pub pen_handles: Vec<[f64; 4]>,
|
||||
/// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the
|
||||
/// curve a click would make, leaving along it.
|
||||
pub rubber_handle: Option<[f64; 2]>,
|
||||
/// Frame/section the pointer is drawing or dropping into (highlighted).
|
||||
pub drop_target: Option<NodeId>,
|
||||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||||
@@ -397,6 +403,14 @@ pub(crate) enum Gesture {
|
||||
},
|
||||
}
|
||||
|
||||
/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in
|
||||
/// and out handles and the one before's out handle, those that aren't zero.
|
||||
fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> {
|
||||
let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]);
|
||||
let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o));
|
||||
before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect()
|
||||
}
|
||||
|
||||
/// The pen tool's in-progress path (page coordinates).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PenPath {
|
||||
@@ -530,12 +544,16 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str {
|
||||
ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl")
|
||||
}
|
||||
|
||||
/// The fill new text takes to be readable where it is drawn: white on a dark `page` (None: the
|
||||
/// model's default, black). For text made in the app: by the Text tool, or by a command that
|
||||
/// names no fill while the interface is up.
|
||||
pub(crate) fn readable_text_fills(doc: &Document, page: NodeId) -> Option<Vec<figcraft_color::Paint>> {
|
||||
matches!(doc.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark())
|
||||
.then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
|
||||
/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark
|
||||
/// background (None: the model's default, black). The background is the nearest layer from `parent`
|
||||
/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made
|
||||
/// in the app: by the Text tool, or by a command that names no fill while the interface is up.
|
||||
pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option<Vec<figcraft_color::Paint>> {
|
||||
let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind {
|
||||
NodeKind::Page { background, .. } => Some(background.is_dark()),
|
||||
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()),
|
||||
});
|
||||
dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
|
||||
}
|
||||
|
||||
const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"];
|
||||
@@ -617,6 +635,18 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
|
||||
handle_at_corners(&corners, p, zoom, is_line)
|
||||
}
|
||||
|
||||
/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels,
|
||||
/// upright whatever the box's rotation).
|
||||
const CORNER_SQUARE_PX: f64 = 4.0;
|
||||
|
||||
/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner
|
||||
/// resizes, although a corner-radius handle 10 px in reaches that far too.
|
||||
fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool {
|
||||
let reach = CORNER_SQUARE_PX / zoom.max(1e-6);
|
||||
matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_)))
|
||||
&& selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach))
|
||||
}
|
||||
|
||||
/// A box whose sides are both shorter than this on screen (pixels) would be covered by its
|
||||
/// handles: the canvas draws just its outline (the handles still answer just outside it).
|
||||
pub const SMALL_BOX_PX: f64 = 16.0;
|
||||
@@ -685,7 +715,18 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor {
|
||||
Handle::Corner(i) => {
|
||||
let a = c.get(i as usize).copied().unwrap_or_default();
|
||||
let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default();
|
||||
Cursor::Resize(angle(a, b))
|
||||
// Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a
|
||||
// diagonal cursor. A box with no width or height falls back to the diagonal.
|
||||
let unit = |v: figcraft_geom::kurbo::Vec2| {
|
||||
let len = v.hypot();
|
||||
(len.is_finite() && len > 1e-9).then(|| v / len)
|
||||
};
|
||||
let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default();
|
||||
let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default();
|
||||
match (unit(next - a), unit(prev - a)) {
|
||||
(Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()),
|
||||
_ => Cursor::Resize(angle(a, b)),
|
||||
}
|
||||
}
|
||||
Handle::Edge(i) => {
|
||||
let a = c.get(i as usize).copied().unwrap_or_default();
|
||||
@@ -982,7 +1023,11 @@ impl Session {
|
||||
let mut measure_box = None;
|
||||
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
self.tool.overlay.arc_hover = arc_hover;
|
||||
let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) {
|
||||
crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.tool.overlay.shape_hover = shape_hover;
|
||||
if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited {
|
||||
cursor = Cursor::Text;
|
||||
@@ -1026,8 +1071,9 @@ impl Session {
|
||||
let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? };
|
||||
guides = g;
|
||||
self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom));
|
||||
if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
|
||||
self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]);
|
||||
}
|
||||
}
|
||||
self.tool.overlay.guides = guides;
|
||||
@@ -1090,6 +1136,7 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
if self.arc_handles_shown()
|
||||
&& !on_corner_square(&doc, &sel, ev.p(), ev.zoom)
|
||||
&& let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom)
|
||||
{
|
||||
self.begin_interaction("Corner radius")?;
|
||||
@@ -1164,7 +1211,13 @@ impl Session {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let targets = Targets::gather(&doc, page, &ids);
|
||||
let mut targets = Targets::gather(&doc, page, &ids);
|
||||
// A copy dragged out with Alt snaps to the layers it came from too (they stay put).
|
||||
if ev.alt
|
||||
&& let Some(b) = targets.moving
|
||||
{
|
||||
targets.add_box(b);
|
||||
}
|
||||
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
|
||||
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
|
||||
@@ -1675,6 +1728,7 @@ impl Session {
|
||||
// dropped; layers in an auto layout frame already sit in their slot.
|
||||
let page = self.page()?;
|
||||
let sel = self.selection();
|
||||
let sources = if duplicate { ids.clone() } else { Vec::new() };
|
||||
let moving = if duplicate { sel } else { ids };
|
||||
let doc = self.doc()?.clone();
|
||||
let in_al =
|
||||
@@ -1703,6 +1757,10 @@ impl Session {
|
||||
self.cancel_interaction()?;
|
||||
} else {
|
||||
self.commit_interaction()?;
|
||||
// Duplicate repeats a free Alt-drag's step (an auto layout places its own).
|
||||
if duplicate && !in_al && moving.len() == sources.len() {
|
||||
self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
@@ -1878,7 +1936,7 @@ impl Session {
|
||||
let at = Point::new(ev.x.round(), (ev.y - 7.0).round());
|
||||
n.transform = pw.inverse() * Affine::translate(at.to_vec2());
|
||||
n.name = String::new();
|
||||
if let Some(fills) = readable_text_fills(d, page) {
|
||||
if let Some(fills) = readable_text_fills(d, parent) {
|
||||
n.fills = fills;
|
||||
}
|
||||
if let Some((w, h)) = figcraft_text::auto_size(&n) {
|
||||
@@ -1931,6 +1989,9 @@ impl Session {
|
||||
|
||||
fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
self.tool.overlay.guides.clear();
|
||||
// The segment being placed shows as itself while the press lasts.
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 };
|
||||
let (doc, page) = {
|
||||
let ds = self.ds()?;
|
||||
@@ -1985,6 +2046,7 @@ impl Session {
|
||||
fn pen_update(&mut self) -> Result<()> {
|
||||
let Some(pen) = self.tool.pen.clone() else { return Ok(()) };
|
||||
self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect();
|
||||
self.tool.overlay.pen_handles = pen_handles(&pen.points);
|
||||
if pen.points.len() < 2 {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
@@ -2067,7 +2129,9 @@ impl Session {
|
||||
pub fn pen_finish(&mut self) -> Result<()> {
|
||||
let pen = self.tool.pen.take();
|
||||
self.tool.overlay.pen.clear();
|
||||
self.tool.overlay.pen_handles.clear();
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
self.tool.gesture = Gesture::None;
|
||||
if pen.as_ref().is_some_and(|p| p.id.is_some()) {
|
||||
self.commit_interaction()?;
|
||||
|
||||
@@ -277,6 +277,35 @@ fn constrain_proportions_keeps_the_other_side_in_ratio() {
|
||||
assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_anywhere_on_a_corner_handle_resizes() {
|
||||
// The canvas draws each corner handle as an 8 px square centred on the corner, and the
|
||||
// corner-radius handle sits 10 px in along the diagonal: a press on the square's inner half
|
||||
// resizes (both sides, in ratio with the link on), it doesn't pick the radius handle.
|
||||
let radii = |s: &Session, r: NodeId| s.doc().unwrap().node(r).unwrap().corners.radii;
|
||||
for lock in [false, true] {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None);
|
||||
set(&mut s, &[r], json!({"constrainProportions": lock}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let on_square = Point::new(997.0, 597.0);
|
||||
s.pointer(&ev(PointerKind::Hover, on_square, NONE)).unwrap();
|
||||
assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)), "lock {lock}: {:?}", s.tool.overlay.cursor);
|
||||
drag(&mut s, on_square, Point::new(1197.0, 637.0), NONE);
|
||||
let (w, h) = size(&s, r);
|
||||
let want_h = if lock { 718.2 } else { 637.0 };
|
||||
assert!(near(w, 1197.0) && near(h, want_h), "lock {lock}: both sides follow the corner, got {w}×{h}");
|
||||
assert_eq!(radii(&s, r), [0.0; 4]);
|
||||
}
|
||||
// Further in, past the square, the radius handle still answers.
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
drag(&mut s, Point::new(993.0, 593.0), Point::new(980.0, 580.0), NONE);
|
||||
assert_eq!(size(&s, r), (1000.0, 600.0));
|
||||
assert!(radii(&s, r)[0] > 0.0, "the radius handle rounds the corners");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_resize_with_constraints_and_cmd_ignores_them() {
|
||||
let mut s = session();
|
||||
@@ -378,6 +407,30 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_cursors_stay_diagonal_whatever_the_proportions() {
|
||||
let mut s = session();
|
||||
let mod180 = |a: f64, b: f64| near((a - b).rem_euclid(180.0).min((b - a).rem_euclid(180.0)), 0.0);
|
||||
for (w, h) in [(65.0, 15.0), (100.0, 100.0), (60.0, 200.0), (200.0, 50.0)] {
|
||||
let r = rect(&mut s, 0.0, 0.0, w, h, None);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let q = selection_box(&doc, &[r]).unwrap();
|
||||
for (i, want) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] {
|
||||
let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() };
|
||||
assert!(mod180(a, want), "{w}x{h} corner {i}: {a}, want {want}");
|
||||
}
|
||||
// Turned, the corner cursors turn with the layer's own axes.
|
||||
set(&mut s, &[r], json!({"rotation": 30}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let q = selection_box(&doc, &[r]).unwrap();
|
||||
let axis = (q[1] - q[0]).atan2().to_degrees();
|
||||
for (i, off) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] {
|
||||
let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() };
|
||||
assert!(mod180(a, axis + off), "{w}x{h} turned corner {i}: {a}, want {}", axis + off);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transform_box_and_its_handles() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -654,14 +654,53 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
|
||||
out
|
||||
}
|
||||
|
||||
/// Save atomically: write a temp file next to `path`, then rename over it.
|
||||
/// Save atomically: write a staging file next to `path`, then rename over it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> Result<(), FormatError> {
|
||||
let bytes = save_bytes(doc, thumbnail_png)?;
|
||||
let tmp = path.with_extension("figcraft.tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
write_file_atomic(path, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a staging file and one atomic rename, so a reader of `path`
|
||||
/// sees either the previous file or the complete new one, never a partial write. The staging file
|
||||
/// is a uniquely named sibling (`<path>.tmp-figcraft-<process id>-<counter>`) created exclusively
|
||||
/// (`create_new`) and written through its own handle: a file or symlink that already exists at a
|
||||
/// staging name is never truncated or followed, and separate writers (also of separate processes)
|
||||
/// never share a staging file. On failure only the staging file this call created is removed, and
|
||||
/// `path` is replaced only on success. The rename replaces an existing destination on every
|
||||
/// platform (`std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows).
|
||||
/// (No `sync_all` yet: nothing around here promises durability across a power cut.)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
|
||||
static STAGING: AtomicU64 = AtomicU64::new(0);
|
||||
// Three tries at a fresh name: the counter is unique within this process, so only a leftover
|
||||
// of a dead process that now shares our process id can collide, and the next counter won't.
|
||||
for _ in 0..3 {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(format!(".tmp-figcraft-{}-{}", std::process::id(), STAGING.fetch_add(1, Relaxed)));
|
||||
let tmp = std::path::PathBuf::from(name);
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(mut file) => {
|
||||
if let Err(e) = file.write_all(bytes) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
return match std::fs::rename(&tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
// Leave nothing behind when the file can't take its place.
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free staging name next to the file"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -677,6 +716,8 @@ mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
mod staging_tests;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! The staging write behind `save_file`: a pre-existing file or symlink at a staging name is never
|
||||
//! truncated or followed, concurrent savers of one file never corrupt it, and a failed write
|
||||
//! cleans up after itself and leaves the destination alone.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::{save_file, write_file_atomic};
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-staging-tests-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges).
|
||||
#[cfg(unix)]
|
||||
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
|
||||
std::os::unix::fs::symlink(to, from).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
|
||||
std::os::windows::fs::symlink_file(to, from).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_staging_file_is_never_written_through() {
|
||||
let d = dir("existing");
|
||||
let path = d.join("a.figcraft");
|
||||
// The old deterministic staging name, held by an unrelated file: saving must pick its own
|
||||
// staging name instead of truncating this one.
|
||||
let old = d.join("a.figcraft.tmp");
|
||||
std::fs::write(&old, b"not yours").unwrap();
|
||||
save_file(&Document::new("Mine"), &path, None).unwrap();
|
||||
assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched");
|
||||
assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine");
|
||||
// Just the file and the untouched staging file: no new staging leftovers.
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_staging_symlink_is_never_followed() {
|
||||
let d = dir("symlink");
|
||||
let path = d.join("a.figcraft");
|
||||
let victim = d.join("victim.txt");
|
||||
std::fs::write(&victim, b"innocent").unwrap();
|
||||
let old = d.join("a.figcraft.tmp");
|
||||
if !symlink(&old, &victim) {
|
||||
return; // Making a symlink needs privileges on some Windows setups.
|
||||
}
|
||||
save_file(&Document::new("Mine"), &path, None).unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through");
|
||||
assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_savers_of_one_file_never_interleave() {
|
||||
let d = dir("concurrent");
|
||||
let path = d.join("a.figcraft");
|
||||
let names = ["W0", "W1", "W2", "W3"];
|
||||
for round in 0..5 {
|
||||
let mut handles = Vec::new();
|
||||
for name in names {
|
||||
let (path, doc) = (path.clone(), Document::new(name));
|
||||
handles.push(std::thread::spawn(move || save_file(&doc, &path, None)));
|
||||
}
|
||||
for h in handles {
|
||||
// On Windows a rename over a file another writer just replaced can lose the race.
|
||||
if cfg!(windows) {
|
||||
let _ = h.join().unwrap();
|
||||
} else {
|
||||
h.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
// Whatever landed is one writer's complete file, never a mix: a torn archive wouldn't load.
|
||||
let (doc, _) = crate::load_file(&path).unwrap();
|
||||
assert!(names.contains(&doc.name.as_str()), "round {round}: {:?}", doc.name);
|
||||
}
|
||||
// No staging file was left stuck.
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
|
||||
let d = dir("failure");
|
||||
// The destination is a directory: the rename fails, our staging file is removed, the
|
||||
// directory is untouched.
|
||||
let taken = d.join("folder.figcraft");
|
||||
std::fs::create_dir_all(&taken).unwrap();
|
||||
assert!(save_file(&Document::new("Mine"), &taken, None).is_err());
|
||||
assert!(write_file_atomic(&taken, b"payload").is_err());
|
||||
assert!(taken.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind");
|
||||
// A missing parent directory fails without creating anything anywhere.
|
||||
assert!(write_file_atomic(&d.join("no").join("such.figcraft"), b"payload").is_err());
|
||||
assert!(!d.join("no").exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// A read-only directory refuses the staging file, and the old destination stays.
|
||||
let ro = d.join("read-only");
|
||||
std::fs::create_dir_all(&ro).unwrap();
|
||||
let dest = ro.join("keep.figcraft");
|
||||
std::fs::write(&dest, b"keep").unwrap();
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_mode(0o500);
|
||||
std::fs::set_permissions(&ro, perms).unwrap();
|
||||
// Running as root would defeat the read-only directory: skip instead of failing.
|
||||
let probe = ro.join(".probe");
|
||||
if std::fs::write(&probe, b"").is_ok() {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
} else {
|
||||
assert!(write_file_atomic(&dest, b"payload").is_err());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"keep");
|
||||
assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind");
|
||||
}
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(&ro, perms).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Deeply nested layers and a resize (#210): constraints, group scaling and auto layout fill
|
||||
//! follow a resize down to [`MAX_NESTING`] levels and stop there, instead of running out of stack.
|
||||
//! Each case runs on a 1 MB stack, the main thread's on Windows; thousands of levels overflowed it.
|
||||
|
||||
use super::*;
|
||||
use figcraft_doc::{AutoLayout, Constraints, Document, HConstraint, NodeKind, VConstraint};
|
||||
|
||||
/// Far deeper than the nesting the layout follows.
|
||||
const DEEP: usize = 5000;
|
||||
|
||||
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
fn width(d: &Document, id: NodeId) -> f64 {
|
||||
d.node(id).unwrap().width
|
||||
}
|
||||
|
||||
/// A 100 × 100 frame holding `depth` frames, each inside the last and stretching with it; the
|
||||
/// root and the chain from the top.
|
||||
fn stretching_frames(depth: usize) -> (Document, NodeId, Vec<NodeId>) {
|
||||
let mut d = Document::new("deep");
|
||||
let page = d.first_page().unwrap();
|
||||
let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let mut chain = Vec::new();
|
||||
let mut parent = root;
|
||||
for _ in 0..depth {
|
||||
let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(f).unwrap().constraints = Constraints { horizontal: HConstraint::LeftRight, vertical: VConstraint::TopBottom };
|
||||
chain.push(f);
|
||||
parent = f;
|
||||
}
|
||||
(d, root, chain)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_frames_follow_their_constraints_without_running_out_of_stack() {
|
||||
on_small_stack(|| {
|
||||
let (mut d, root, chain) = stretching_frames(DEEP);
|
||||
set_size(&mut d, root, 200.0, 200.0);
|
||||
apply_constraints(&mut d, root, 100.0, 100.0);
|
||||
assert_eq!(width(&d, chain[0]), 200.0);
|
||||
assert_eq!(width(&d, chain[MAX_NESTING - 2]), 200.0);
|
||||
assert_eq!(width(&d, chain[DEEP - 1]), 100.0, "past the nesting the layout follows, layers stay");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shallow_chain_follows_all_the_way_down() {
|
||||
let (mut d, root, chain) = stretching_frames(20);
|
||||
set_size(&mut d, root, 200.0, 200.0);
|
||||
apply_constraints(&mut d, root, 100.0, 100.0);
|
||||
assert!(chain.iter().all(|f| width(&d, *f) == 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_groups_scale_without_running_out_of_stack() {
|
||||
on_small_stack(|| {
|
||||
let mut d = Document::new("deep");
|
||||
let mut parent = d.first_page().unwrap();
|
||||
let root = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
parent = root;
|
||||
let mut chain = Vec::new();
|
||||
for _ in 0..DEEP {
|
||||
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
chain.push(parent);
|
||||
}
|
||||
scale_group_children(&mut d, root, 2.0, 2.0);
|
||||
assert_eq!(width(&d, chain[0]), 200.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() {
|
||||
on_small_stack(|| {
|
||||
let mut d = Document::new("deep");
|
||||
let page = d.first_page().unwrap();
|
||||
let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(root).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() });
|
||||
let mut parent = root;
|
||||
let mut chain = Vec::new();
|
||||
for _ in 0..DEEP {
|
||||
let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let n = d.get_mut(f).unwrap();
|
||||
n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() });
|
||||
n.child_layout.sizing_h = Sizing::Fill;
|
||||
chain.push(f);
|
||||
parent = f;
|
||||
}
|
||||
d.get_mut(root).unwrap().width = 300.0;
|
||||
layout_frame(&mut d, root, &NoMeasure);
|
||||
assert_eq!(width(&d, chain[0]), 300.0);
|
||||
assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay");
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,30 @@ use figcraft_doc::{
|
||||
};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
/// Most layout calls nested in one another (about one per level of layers): deeper layers don't
|
||||
/// follow a resize, so a deeply nested document can't run the stack out (#210).
|
||||
pub const MAX_NESTING: usize = 256;
|
||||
|
||||
thread_local! {
|
||||
static NESTING: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// One level of nested layout calls, released when dropped.
|
||||
struct Nested;
|
||||
|
||||
impl Nested {
|
||||
/// Enter one more level; `None` when [`MAX_NESTING`] are in progress.
|
||||
fn enter() -> Option<Self> {
|
||||
NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Nested {
|
||||
fn drop(&mut self) {
|
||||
NESTING.with(|n| n.set(n.get().saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures text for a given width (auto-height text inside a Fill container).
|
||||
pub trait Measure {
|
||||
/// The height a text node needs at `width`.
|
||||
@@ -311,6 +335,7 @@ struct Item {
|
||||
|
||||
/// Lay out one auto layout frame's children and size the frame if it hugs.
|
||||
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
|
||||
let Some(_nested) = Nested::enter() else { return };
|
||||
let Some(f) = doc.node(frame) else { return };
|
||||
let Some(al) = f.layout.clone() else { return };
|
||||
let horizontal = matches!(al.mode, LayoutMode::Horizontal);
|
||||
@@ -893,6 +918,7 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu
|
||||
/// (recursively for children frames that resize). In an auto layout frame only the children that
|
||||
/// ignore auto layout (absolute position) follow constraints; the layout places the others.
|
||||
pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
|
||||
let Some(_nested) = Nested::enter() else { return };
|
||||
let Some(f) = doc.node(id) else { return };
|
||||
let absolute_only = f.layout.is_some();
|
||||
let (nw, nh) = (f.width, f.height);
|
||||
@@ -977,6 +1003,7 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
|
||||
/// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its
|
||||
/// centre following the group's scale.
|
||||
pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) {
|
||||
let Some(_nested) = Nested::enter() else { return };
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
for c in n.children.clone() {
|
||||
let Some(cn) = doc.node(c) else { continue };
|
||||
@@ -1101,6 +1128,8 @@ mod behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod content_align_tests;
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+6
-62
@@ -3,6 +3,7 @@
|
||||
//! behave the same.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_engine::params::normalize_paint;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::backend::{Backend, decode_data, page_children};
|
||||
@@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> {
|
||||
acc
|
||||
}
|
||||
|
||||
/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS
|
||||
/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get
|
||||
/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given
|
||||
/// as plain colours are spread evenly.
|
||||
/// Make paint/effect JSON friendlier than the engine's strict serde (see
|
||||
/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects
|
||||
/// still need it here.
|
||||
pub fn normalize_props(props: &mut Map<String, Value>) {
|
||||
for k in ["fills", "strokes", "effects"] {
|
||||
if let Some(v) = props.get_mut(k) {
|
||||
normalize_paintish(v, 0);
|
||||
normalize_paint(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_paintish(v: &mut Value, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => {
|
||||
for x in a {
|
||||
normalize_paintish(x, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) {
|
||||
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
|
||||
}
|
||||
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
|
||||
if is_gradient {
|
||||
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
|
||||
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
|
||||
&& t != "gradient"
|
||||
{
|
||||
let kind = t.trim_end_matches("gradient").to_string();
|
||||
o.insert("type".into(), json!("gradient"));
|
||||
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
|
||||
}
|
||||
o.entry("kind").or_insert(json!("linear"));
|
||||
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
|
||||
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
|
||||
o.entry("from").or_insert(json!(from));
|
||||
o.entry("to").or_insert(json!(to));
|
||||
// A linear gradient's width handle goes square to its axis (else its bands skew,
|
||||
// or vanish when the handle lies on the axis).
|
||||
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
if linear
|
||||
&& !o.contains_key("width")
|
||||
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
|
||||
{
|
||||
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
|
||||
}
|
||||
o.entry("width").or_insert(json!(width));
|
||||
if let Some(Value::Array(stops)) = o.get_mut("stops") {
|
||||
let n = stops.len();
|
||||
for (i, s) in stops.iter_mut().enumerate() {
|
||||
if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) {
|
||||
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
*s = json!({"position": pos, "color": c});
|
||||
} else {
|
||||
normalize_paintish(s, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`.
|
||||
fn friendly_params(p: &mut Value) {
|
||||
let Some(o) = p.as_object_mut() else { return };
|
||||
@@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) {
|
||||
normalize_props(o);
|
||||
for k in ["paint", "effect"] {
|
||||
if let Some(v) = o.get_mut(k) {
|
||||
normalize_paintish(v, 0);
|
||||
normalize_paint(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -86,6 +86,12 @@ fn clip_attr(clip: &Option<String>) -> String {
|
||||
clip.as_ref().map(|c| format!(" clip-path=\"url(#{c})\"")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `el` in a group with `clip` (a ` clip-path` attribute, or nothing). A clip path is in page
|
||||
/// coordinates, and on an element with a `transform` it would be read after that transform.
|
||||
fn clipped(clip: &str, el: String) -> String {
|
||||
if clip.is_empty() { el } else { format!("<g{clip}>{el}</g>") }
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
@@ -433,7 +439,8 @@ impl W<'_> {
|
||||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.fills {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(path));
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,7 +512,7 @@ impl W<'_> {
|
||||
if n.has_visible_stroke()
|
||||
&& let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten()
|
||||
{
|
||||
self.body.push_str(&simple);
|
||||
self.body.push_str(&clipped(&paint_clip, simple));
|
||||
} else if n.has_visible_stroke() {
|
||||
let geom = match &n.kind {
|
||||
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
|
||||
@@ -517,7 +524,8 @@ impl W<'_> {
|
||||
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.strokes {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(&area));
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,3 +43,42 @@ fn import_gradient_and_groups() {
|
||||
assert!(kinds.contains(&"GROUP"));
|
||||
assert!(kinds.contains(&"RECTANGLE") && kinds.contains(&"ELLIPSE"), "{kinds:?}");
|
||||
}
|
||||
|
||||
/// #325: a frame with children inside a clipping frame keeps its own paint inside the clip. The
|
||||
/// clip path is in page coordinates, so it must sit on a group around the transformed path, never
|
||||
/// on the path itself (where it would be read after the path's transform, offset by it).
|
||||
#[test]
|
||||
fn nested_frame_paint_is_clipped_in_page_space() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let page = d.create(p, NodeKind::Frame, "Page", 0.0, 0.0, 400.0, 300.0).unwrap();
|
||||
d.get_mut(page).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
|
||||
let card = d.create(page, NodeKind::Frame, "Card", 40.0, 60.0, 300.0, 120.0).unwrap();
|
||||
let c = d.get_mut(card).unwrap();
|
||||
c.fills = vec![Paint::solid(Color::from_hex("DDDDDD").unwrap())];
|
||||
c.corners.radii = [12.0; 4];
|
||||
c.clip = true;
|
||||
let badge = d.create(card, NodeKind::Frame, "Badge", 200.0, 40.0, 80.0, 40.0).unwrap();
|
||||
let b = d.get_mut(badge).unwrap();
|
||||
b.fills = vec![Paint::solid(Color::from_hex("1E6F8E").unwrap())];
|
||||
b.strokes = vec![Paint::solid(Color::from_hex("AA0000").unwrap())];
|
||||
b.stroke.weight = 2.0;
|
||||
let dot = d.create(badge, NodeKind::Rectangle, "Dot", 10.0, 10.0, 20.0, 20.0).unwrap();
|
||||
d.get_mut(dot).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
|
||||
for simplify_stroke in [true, false] {
|
||||
let svg = export_svg(&d, &[page], &SvgOptions { simplify_stroke, ..SvgOptions::default() }).unwrap();
|
||||
for tag in svg.split('<').filter(|t| t.contains(" clip-path=")) {
|
||||
assert!(!tag.contains(" transform="), "clip-path on a transformed element: <{tag}\n{svg}");
|
||||
}
|
||||
// The card's clip: its rounded outline starts after the top-left radius.
|
||||
let def = svg.find("<path d=\"M52 60").unwrap();
|
||||
let open = svg[..def].rfind("<clipPath id=\"").unwrap() + "<clipPath id=\"".len();
|
||||
let card_clip = &svg[open..open + svg[open..].find('"').unwrap()];
|
||||
let wrapped = format!("<g clip-path=\"url(#{card_clip})\">");
|
||||
for color in ["#1E6F8E", "#AA0000"] {
|
||||
let at = svg.find(color).unwrap_or_else(|| panic!("{color} missing\n{svg}"));
|
||||
let path = svg[..at].rfind("<path").unwrap();
|
||||
assert!(svg[..path].ends_with(&wrapped), "{color} not in the card's clip\n{svg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,7 +1213,8 @@ impl TextLayout {
|
||||
}
|
||||
let (x0, _) = self.caret(a.max(line.char_start));
|
||||
let x1 = if b >= line.char_end {
|
||||
self.line_end_x(line) + if line.para_end && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
||||
// The last line has no paragraph break to show, so it ends at its glyphs.
|
||||
self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
||||
} else {
|
||||
self.caret(b).0
|
||||
};
|
||||
|
||||
@@ -130,14 +130,23 @@ fn hanging_punctuation_outside_the_aligned_edge() {
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
let Some(plain) = laid(&t) else { return };
|
||||
let open = plain.glyphs[0].advance;
|
||||
// Kerning may draw a glyph off its pen position (an x offset, e.g. Helvetica Neue's "re"), so
|
||||
// each hanging line is checked against the same line set flush: every glyph moves by exactly
|
||||
// the hanging mark's advance, which puts the letters' pen edge on the box edge.
|
||||
let moved = |a: &TextLayout, b: &TextLayout, by: f64| {
|
||||
a.glyphs.len() == b.glyphs.len() && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| (a.x - b.x - by).abs() < EPS)
|
||||
};
|
||||
t.hanging_punctuation = true;
|
||||
let l = laid(&t).unwrap();
|
||||
assert!((l.glyphs[0].x + open).abs() < EPS && l.glyphs[1].x.abs() < EPS, "left: the quote hangs, Q starts the line");
|
||||
assert!(plain.glyphs[0].x.abs() < EPS && moved(&l, &plain, -open), "left: the quote hangs, Q starts the line");
|
||||
t.align_h = TextAlignH::Right;
|
||||
t.hanging_punctuation = false;
|
||||
let flush = laid(&t).unwrap();
|
||||
t.hanging_punctuation = true;
|
||||
let l = laid(&t).unwrap();
|
||||
let last = l.glyphs.last().unwrap();
|
||||
let before = &l.glyphs[l.glyphs.len() - 2];
|
||||
assert!((before.x + before.advance - 400.0).abs() < EPS && last.x >= 400.0 - EPS, "right: the full stop hangs past the edge");
|
||||
let stop = l.glyphs.last().unwrap().advance;
|
||||
let edge = flush.lines[0].x + flush.lines[0].width;
|
||||
assert!((edge - 400.0).abs() < EPS && moved(&l, &flush, stop), "right: the full stop hangs past the edge");
|
||||
t.align_h = TextAlignH::Center;
|
||||
let (a, b) = (laid(&t).unwrap(), {
|
||||
t.hanging_punctuation = false;
|
||||
|
||||
@@ -416,3 +416,24 @@ fn arrows_move_visually_through_right_to_left_runs() {
|
||||
// Plain left-to-right lines step by char (the caller's default).
|
||||
assert!(layout(&TextData::new("plain"), None, None).visual_step(1, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_through_text_end_stays_inside_auto_width_box() {
|
||||
if !have_fonts() {
|
||||
return;
|
||||
}
|
||||
for size in [12.0, 24.0, 96.0] {
|
||||
let mut t = TextData::new("Hello world");
|
||||
t.style.font_size = size;
|
||||
let l = layout(&t, None, None);
|
||||
let n = "Hello world".chars().count();
|
||||
let right = l.selection_rects(0, n).last().map_or(f64::NAN, |r| r.x1);
|
||||
assert!(right <= l.width.ceil(), "size {size}: right {right} > ceil(width) {}", l.width.ceil());
|
||||
}
|
||||
// A selected paragraph break still shows past the end of its line.
|
||||
let t = TextData::new("Hello\nworld");
|
||||
let l = layout(&t, None, None);
|
||||
let r = l.selection_rects(0, 6);
|
||||
let first = r.first().map_or(f64::NAN, |r| r.x1);
|
||||
assert!(first > l.caret(5).0 + 1.0, "{first}");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer"
|
||||
"About" = "Über"
|
||||
"About FigCraft" = "Über FigCraft"
|
||||
|
||||
@@ -16,6 +16,7 @@ name = "English"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Una capa sola se alinea con su marco: selecciona más capas o una dentro de un marco"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un título, una agenda, una gran cifra, un gráfico y una diapositiva de cierre"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"About" = "À propos"
|
||||
"About FigCraft" = "À propos de FigCraft"
|
||||
|
||||
@@ -19,7 +19,11 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド"
|
||||
"A to Z" = "昇順(A→Z)"
|
||||
"About" = "情報"
|
||||
"About FigCraft" = "FigCraft について"
|
||||
"About this server" = "このサーバーについて"
|
||||
@@ -90,7 +94,9 @@ status = "draft — needs native review"
|
||||
"All supported properties" = "サポートされているすべてのプロパティ"
|
||||
"All variables" = "すべての変数"
|
||||
"Alpha" = "アルファ"
|
||||
"Alphabetical" = "名前順"
|
||||
"Alt+drag" = "Alt+ドラッグ"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表"
|
||||
"An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク"
|
||||
"Anchor" = "アンカー"
|
||||
"Android" = "Android"
|
||||
@@ -109,6 +115,8 @@ status = "draft — needs native review"
|
||||
"Apply variable" = "変数を適用"
|
||||
"Apply variable to text" = "テキストに変数を適用"
|
||||
"Apply variable to visibility" = "表示に変数を適用"
|
||||
"Apps" = "アプリ"
|
||||
"Apr" = "4月"
|
||||
"Arc" = "円弧"
|
||||
"Arc ratio" = "円弧の比率"
|
||||
"Arc start" = "円弧の開始"
|
||||
@@ -120,6 +128,7 @@ status = "draft — needs native review"
|
||||
"As typed" = "入力どおり"
|
||||
"Asking the server…" = "サーバーに問い合わせ中…"
|
||||
"Assets" = "アセット"
|
||||
"Aug" = "8月"
|
||||
"Auto" = "自動"
|
||||
"Auto (space around)" = "自動(周囲に間隔)"
|
||||
"Auto (space between)" = "自動(間に間隔)"
|
||||
@@ -164,6 +173,7 @@ status = "draft — needs native review"
|
||||
"Bottom" = "下"
|
||||
"Bottom left radius" = "左下の半径"
|
||||
"Bottom right radius" = "右下の半径"
|
||||
"Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。"
|
||||
"Branch" = "ブランチ"
|
||||
"Branch archived" = "ブランチをアーカイブ済み"
|
||||
"Branch merged" = "ブランチをマージ済み"
|
||||
@@ -250,6 +260,7 @@ status = "draft — needs native review"
|
||||
"Color values in this file are in this color space." = "このファイルのカラー値はこのカラースペースで表される。"
|
||||
"Color variables" = "カラー変数"
|
||||
"Colour" = "カラー"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "カラー・テキストスタイル、ライト・ダークの変数、ボタン、入力欄、カード"
|
||||
"Column" = "列"
|
||||
"Column gap" = "列の間隔"
|
||||
"Column sizes" = "列のサイズ"
|
||||
@@ -316,6 +327,7 @@ status = "draft — needs native review"
|
||||
"Create property…" = "プロパティを作成…"
|
||||
"Create style" = "スタイルを作成"
|
||||
"Create style from the selected text" = "選択したテキストからスタイルを作成"
|
||||
"Created" = "作成日"
|
||||
"Crop" = "切り抜き"
|
||||
"Crop marks" = "トンボ"
|
||||
"Ctrl+arrows" = "Ctrl+矢印キー"
|
||||
@@ -329,6 +341,9 @@ status = "draft — needs native review"
|
||||
"Dark" = "ダーク"
|
||||
"Darken" = "比較(暗)"
|
||||
"Dash" = "破線"
|
||||
"Dashboard" = "ダッシュボード"
|
||||
"Date created" = "作成日"
|
||||
"Dec" = "12月"
|
||||
"Decoration style" = "装飾のスタイル"
|
||||
"Decoration" = "装飾"
|
||||
"Deep select" = "深い階層を選択"
|
||||
@@ -355,6 +370,7 @@ status = "draft — needs native review"
|
||||
"Description" = "説明"
|
||||
"Description (optional)" = "説明(任意)"
|
||||
"Design" = "デザイン"
|
||||
"Design system starter" = "デザインシステムの雛形"
|
||||
"Desktop" = "デスクトップ"
|
||||
"Destination" = "遷移先"
|
||||
"Detach alias" = "エイリアスを解除"
|
||||
@@ -390,6 +406,7 @@ status = "draft — needs native review"
|
||||
"Drafts" = "下書き"
|
||||
"Drag the stroke to add a width point" = "線をドラッグして幅のポイントを追加"
|
||||
"Draw" = "描画"
|
||||
"Draw a frame" = "フレームを描く"
|
||||
"Drawing tools" = "描画ツール"
|
||||
"Drop shadow" = "ドロップシャドウ"
|
||||
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT または Apache-2.0 のデュアルライセンス。ArtCraft チームとコミュニティが制作。"
|
||||
@@ -408,6 +425,7 @@ status = "draft — needs native review"
|
||||
"Edit style" = "スタイルを編集"
|
||||
"Edit variable" = "変数を編集"
|
||||
"Edit with the builder" = "ビルダーで編集"
|
||||
"Edited {when}" = "最終編集:{when}"
|
||||
"Effect settings" = "エフェクトの設定"
|
||||
"Effect styles" = "エフェクトスタイル"
|
||||
"Effects" = "エフェクト"
|
||||
@@ -438,6 +456,7 @@ status = "draft — needs native review"
|
||||
"Expand" = "展開"
|
||||
"Export" = "書き出し"
|
||||
"Export all" = "すべて書き出し"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "フレームやレイヤーを PNG、JPG、SVG、PDF で書き出し"
|
||||
"Export page as PNG" = "ページを PNG で書き出し"
|
||||
"Export settings" = "書き出し設定"
|
||||
"Export variables" = "変数を書き出し"
|
||||
@@ -457,9 +476,11 @@ status = "draft — needs native review"
|
||||
"F" = "F"
|
||||
"Fade" = "フェード"
|
||||
"False" = "false"
|
||||
"Feb" = "2月"
|
||||
"Fetching from the server…" = "サーバーから取得中…"
|
||||
"File" = "ファイル"
|
||||
"File name" = "ファイル名"
|
||||
"File not found" = "ファイルが見つからない"
|
||||
"File published" = "ファイルを公開済み"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "ファイルはエンドツーエンドで暗号化されない:サーバーの運営者は内容を読める。"
|
||||
"Files nobody opens for {n} days are deleted." = { other = "{n} 日間誰も開かなかったファイルは削除される。" }
|
||||
@@ -506,6 +527,7 @@ status = "draft — needs native review"
|
||||
"Font size" = "フォントサイズ"
|
||||
"Font style" = "フォントスタイル"
|
||||
"Forget on this computer" = "このコンピューターで記録を消去"
|
||||
"Four screens of a trail app with components and a prototype flow" = "コンポーネントとプロトタイプのフローを備えた登山アプリの4画面"
|
||||
"Fractions" = "分数"
|
||||
"Frame" = "フレーム"
|
||||
"Frame selection" = "選択範囲をフレームで囲む"
|
||||
@@ -519,6 +541,7 @@ status = "draft — needs native review"
|
||||
"Gap between columns" = "列の間隔"
|
||||
"Gap between items" = "アイテムの間隔"
|
||||
"Gap between rows" = "行の間隔"
|
||||
"Getting started" = "使い始めるには"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "一般公開されているサーバーのアドレス、またはキー付きのサーバーのアドレスを入力(`figcraft-server key` で表示)。"
|
||||
"Glass" = "ガラス"
|
||||
"Go to main component" = "メインコンポーネントへ移動"
|
||||
@@ -527,6 +550,7 @@ status = "draft — needs native review"
|
||||
"Gradient" = "グラデーション"
|
||||
"Gray" = "グレー"
|
||||
"Green" = "緑"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "フローを考えるためのグレースケールのコンポーネントと2つの画面例"
|
||||
"Grid" = "グリッド"
|
||||
"Grid ({size})" = "グリッド({size})"
|
||||
"Grid styles" = "グリッドスタイル"
|
||||
@@ -548,6 +572,8 @@ status = "draft — needs native review"
|
||||
"Hide contents" = "コンテンツを非表示"
|
||||
"Hide from publishing" = "公開対象から除外"
|
||||
"Hide section contents" = "セクションのコンテンツを非表示"
|
||||
"Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)"
|
||||
"Hide when publishing" = "公開時に非表示"
|
||||
"Highlight layers on hover" = "ホバー時にレイヤーをハイライト"
|
||||
"Highlights" = "ハイライト"
|
||||
"Hold Alt" = "Alt を押したまま"
|
||||
@@ -569,6 +595,8 @@ status = "draft — needs native review"
|
||||
"Hue" = "色相"
|
||||
"Hug" = "ハグ"
|
||||
"Hug contents" = "内容に合わせる"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー"
|
||||
"Icon set starter" = "アイコンセットの雛形"
|
||||
"If" = "条件"
|
||||
"If {condition}" = "条件:{condition}"
|
||||
"Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)"
|
||||
@@ -613,6 +641,7 @@ status = "draft — needs native review"
|
||||
"Invite link" = "招待リンク"
|
||||
"Invite link copied" = "招待リンクをコピー済み"
|
||||
"It asks permission to:" = "次の権限を求めている:"
|
||||
"Jan" = "1月"
|
||||
"Join" = "参加"
|
||||
"Join code copied" = "参加コードをコピー済み"
|
||||
"Join code or invite link" = "参加コードまたは招待リンク"
|
||||
@@ -623,6 +652,8 @@ status = "draft — needs native review"
|
||||
"Joined {file}" = "{file} に参加済み"
|
||||
"Joining…" = "参加中…"
|
||||
"Join|stroke" = "角の形状"
|
||||
"Jul" = "7月"
|
||||
"Jun" = "6月"
|
||||
"Just now" = "たった今"
|
||||
"Justify" = "両端揃え"
|
||||
"Keep appearance" = "見た目を保持"
|
||||
@@ -632,11 +663,14 @@ status = "draft — needs native review"
|
||||
"Key/Gamepad" = "キー/ゲームパッド"
|
||||
"Key/Gamepad {key}" = "キー/ゲームパッド {key}"
|
||||
"Keyboard shortcuts" = "キーボードショートカット"
|
||||
"Kits" = "キット"
|
||||
"Landing page" = "ランディングページ"
|
||||
"Language" = "言語"
|
||||
"Lasso" = "なげなわ"
|
||||
"Last" = "最後"
|
||||
"Last change: {note}" = "最後の変更:{note}"
|
||||
"Last commit" = "最後のコミット"
|
||||
"Last modified" = "更新日"
|
||||
"Last on top" = "最後を上に"
|
||||
"Layer blur" = "レイヤーのぼかし"
|
||||
"Layer name" = "レイヤー名"
|
||||
@@ -675,6 +709,7 @@ status = "draft — needs native review"
|
||||
"Link" = "リンク"
|
||||
"Link copied" = "リンクをコピー済み"
|
||||
"Link docs, code or tickets for developers." = "開発者向けにドキュメント、コード、チケットをリンク。"
|
||||
"Link frames on the Prototype tab, then present the flow" = "プロトタイプタブでフレームをつなぎ、フローを再生"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "このコンポーネントをコードコンポーネントにリンクすると、インスタンスに実際のスニペットがその値とともに表示される。"
|
||||
"List spacing" = "リストの間隔"
|
||||
"List style" = "リストのスタイル"
|
||||
@@ -692,7 +727,9 @@ status = "draft — needs native review"
|
||||
"Main" = "メイン"
|
||||
"Main component deleted" = "メインコンポーネントは削除済み"
|
||||
"Main menu" = "メインメニュー"
|
||||
"Make a component" = "コンポーネントを作成"
|
||||
"Make slot" = "スロットにする"
|
||||
"Mar" = "3月"
|
||||
"Margin" = "マージン"
|
||||
"Mark all as read" = "すべて既読にする"
|
||||
"Mark as resolved" = "解決済みにする"
|
||||
@@ -705,6 +742,7 @@ status = "draft — needs native review"
|
||||
"Max W" = "最大 W"
|
||||
"Max lines" = "最大行数"
|
||||
"Maximize" = "最大化"
|
||||
"May" = "5月"
|
||||
"Measure to the selection" = "選択範囲までの距離を測定"
|
||||
"Merge into main" = "メインにマージ"
|
||||
"Merge regions" = "領域を結合"
|
||||
@@ -726,6 +764,7 @@ status = "draft — needs native review"
|
||||
"Mixed" = "混在"
|
||||
"Mixed fonts" = "混在フォント"
|
||||
"Mixed interactions: click + to replace them" = "混在したインタラクション:+ をクリックして置き換え"
|
||||
"Mobile app" = "モバイルアプリ"
|
||||
"Mode" = "モード"
|
||||
"Model" = "モデル"
|
||||
"Models" = "モデル"
|
||||
@@ -755,6 +794,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "移動先"
|
||||
"Nested instance" = "ネストされたインスタンス"
|
||||
"New design file" = "新規デザインファイル"
|
||||
"Newest first" = "新しい順"
|
||||
"Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール"
|
||||
"Next ready for dev" = "次の開発準備完了"
|
||||
"Next size option" = "次のサイズオプション"
|
||||
@@ -797,6 +837,7 @@ status = "draft — needs native review"
|
||||
"No string variables yet. Create them in Local variables." = "文字列変数はまだない。ローカル変数で作成。"
|
||||
"No styles yet." = "スタイルはまだない。"
|
||||
"No styles yet. Click + to create one from the selection." = "スタイルはまだない。+ をクリックして選択範囲から作成。"
|
||||
"No templates match your search" = "検索に一致するテンプレートなし"
|
||||
"No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。"
|
||||
"No variables fit here." = "ここに使える変数はない。"
|
||||
"No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。"
|
||||
@@ -816,6 +857,7 @@ status = "draft — needs native review"
|
||||
"Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。"
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。"
|
||||
"Nothing to export" = "書き出すものがない"
|
||||
"Nov" = "11月"
|
||||
"Now" = "現在"
|
||||
"Nudge (Shift: big nudge)" = "微調整(Shift:大きく微調整)"
|
||||
"Nudge amount" = "微調整の量"
|
||||
@@ -824,13 +866,16 @@ status = "draft — needs native review"
|
||||
"Number ↓" = "番号 ↓"
|
||||
"Numbered list" = "番号付きリスト"
|
||||
"Object" = "オブジェクト"
|
||||
"Oct" = "10月"
|
||||
"Off" = "オフ"
|
||||
"Offered first when filling the slot (any content is allowed)" = "スロットを埋めるときに最初に提示(どのコンテンツも使用可)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "インスタンスを入れ替えるときに最初に提示(どのコンポーネントも使用可)"
|
||||
"Offset" = "オフセット"
|
||||
"Oldest first" = "古い順"
|
||||
"On click" = "クリック時"
|
||||
"On drag" = "ドラッグ時"
|
||||
"On this page" = "このページ"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "1つのお知らせを5種類の一般的なサイズで:正方形、縦長、ストーリー、リンク、バナー"
|
||||
"Online" = "オンライン"
|
||||
"Online files" = "オンラインファイル"
|
||||
"Online sharing is off: files are shared on the local network only." = "オンライン共有はオフ:ファイルはローカルネットワークでのみ共有される。"
|
||||
@@ -857,6 +902,7 @@ status = "draft — needs native review"
|
||||
"Open|comments" = "未解決"
|
||||
"Options" = "オプション"
|
||||
"Orange" = "オレンジ"
|
||||
"Order" = "並び順"
|
||||
"Other apps may not open every .fig file exported here." = "ここで書き出した .fig ファイルは、他のアプリで開けない場合がある。"
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "他の人はアクセスできなくなり、各自のコピーは残る。ファイルはあなた用にサーバーに残る"
|
||||
"Others type this under Share ▸ Join with a code" = "他の人は「共有 ▸ コードで参加」でこれを入力"
|
||||
@@ -903,6 +949,7 @@ status = "draft — needs native review"
|
||||
"Pick color" = "色を抽出"
|
||||
"Pick color (I)" = "色を抽出(I)"
|
||||
"Pick one font family first" = "先にフォントファミリーを 1 つ選択"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "フレームツールを選び、キャンバスをドラッグするか端末のサイズを選択"
|
||||
"Pin a property" = "プロパティを固定"
|
||||
"Pinch" = "すぼめる"
|
||||
"Pixel grid" = "ピクセルグリッド"
|
||||
@@ -911,6 +958,7 @@ status = "draft — needs native review"
|
||||
"Place a shape or frame (its tool chosen)" = "シェイプまたはフレームを配置(ツール選択中)"
|
||||
"Place automatically" = "自動で配置"
|
||||
"Place image" = "画像を配置"
|
||||
"Playground" = "プレイグラウンド"
|
||||
"Plugins" = "プラグイン"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。"
|
||||
"Plus darker" = "プラス(暗)"
|
||||
@@ -928,6 +976,7 @@ status = "draft — needs native review"
|
||||
"Present or preview" = "プレゼンテーションまたはプレビュー"
|
||||
"Present this flow" = "このフローをプレゼンテーション"
|
||||
"Presentation" = "プレゼンテーション"
|
||||
"Presentation slides" = "プレゼンテーションのスライド"
|
||||
"Presenting: comments" = "プレゼンテーション中:コメント"
|
||||
"Presenting: cycle the size options" = "プレゼンテーション中:サイズオプションを切り替え"
|
||||
"Presenting: full screen" = "プレゼンテーション中:全画面表示"
|
||||
@@ -983,6 +1032,7 @@ status = "draft — needs native review"
|
||||
"Remove auto layout" = "オートレイアウトを解除"
|
||||
"Remove dev resource" = "開発リソースを削除"
|
||||
"Remove export setting" = "書き出し設定を削除"
|
||||
"Remove from recents" = "最近使ったファイルから削除"
|
||||
"Remove from server" = "サーバーから削除"
|
||||
"Remove from server…" = "サーバーから削除…"
|
||||
"Remove guide" = "ガイドを削除"
|
||||
@@ -1092,15 +1142,18 @@ status = "draft — needs native review"
|
||||
"Search layers" = "レイヤーを検索"
|
||||
"Search plugins" = "プラグインを検索"
|
||||
"Search shortcuts" = "ショートカットを検索"
|
||||
"Search templates" = "テンプレートを検索"
|
||||
"Section" = "セクション"
|
||||
"Section color" = "セクションの色"
|
||||
"Sections" = "セクション"
|
||||
"See all templates" = "すべてのテンプレートを見る"
|
||||
"Select a frame and add a starting point to present it as a flow." = "フレームを選択して開始点を追加すると、フローとしてプレゼンテーションできる。"
|
||||
"Select a frame to preview it" = "フレームを選択するとプレビュー"
|
||||
"Select a layer to see its properties and code" = "レイヤーを選択するとプロパティとコードを表示"
|
||||
"Select all" = "すべて選択"
|
||||
"Select all with same" = "同じ項目をすべて選択"
|
||||
"Select layer" = "レイヤーを選択"
|
||||
"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整"
|
||||
"Select layers to apply the style to" = "スタイルを適用するレイヤーを選択"
|
||||
"Select overlapping shapes" = "重なったシェイプを選択"
|
||||
"Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる"
|
||||
@@ -1111,6 +1164,7 @@ status = "draft — needs native review"
|
||||
"Send backward" = "背面へ"
|
||||
"Send report" = "報告を送信"
|
||||
"Send to back" = "最背面へ"
|
||||
"Sep" = "9月"
|
||||
"Server address" = "サーバーのアドレス"
|
||||
"Server address (ws://host:47900/)" = "サーバーのアドレス(ws://host:47900/)"
|
||||
"Server for online sharing" = "オンライン共有用のサーバー"
|
||||
@@ -1125,6 +1179,7 @@ status = "draft — needs native review"
|
||||
"Shape tools" = "シェイプツール"
|
||||
"Share" = "共有"
|
||||
"Share this file" = "このファイルを共有"
|
||||
"Share with a code" = "コードで共有"
|
||||
"Share: live session, export" = "共有:ライブセッション、書き出し"
|
||||
"Shared" = "共有中"
|
||||
"Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー"
|
||||
@@ -1134,6 +1189,7 @@ status = "draft — needs native review"
|
||||
"Sharing online · {people}" = "オンラインで共有中 · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "共有を停止:ファイルはサーバーに残り、開けるのはあなただけ"
|
||||
"Show" = "表示"
|
||||
"Show all templates" = "すべてのテンプレートを表示"
|
||||
"Show as grid" = "グリッドで表示"
|
||||
"Show as list" = "リストで表示"
|
||||
"Show autosaves" = "自動保存を表示"
|
||||
@@ -1142,6 +1198,9 @@ status = "draft — needs native review"
|
||||
"Show device frame" = "デバイスフレームを表示"
|
||||
"Show dimensions on objects" = "オブジェクトに寸法を表示"
|
||||
"Show hotspot hints" = "ホットスポットのヒントを表示"
|
||||
"Show in Explorer" = "エクスプローラーで表示"
|
||||
"Show in Finder" = "Finder で表示"
|
||||
"Show in folder" = "フォルダーで表示"
|
||||
"Show only this on the canvas" = "キャンバスにこれだけを表示"
|
||||
"Show open" = "未解決を表示"
|
||||
"Show section contents" = "セクションのコンテンツを表示"
|
||||
@@ -1166,13 +1225,17 @@ status = "draft — needs native review"
|
||||
"Small caps" = "スモールキャップ"
|
||||
"Smart animate" = "スマートアニメート"
|
||||
"Snap to pixel grid" = "ピクセルグリッドにスナップ"
|
||||
"Social and slides" = "SNS・スライド"
|
||||
"Social media" = "ソーシャルメディア"
|
||||
"Social post sizes" = "SNS投稿サイズ"
|
||||
"Soft light" = "ソフトライト"
|
||||
"Solid" = "実線"
|
||||
"Some variants have the same property values: give each one a unique combination" = "同じプロパティ値を持つバリアントがある:それぞれ固有の組み合わせにする"
|
||||
"Sort" = "並べ替え"
|
||||
"Sort by" = "並べ替えの基準"
|
||||
"Sort by date" = "日付で並べ替え"
|
||||
"Sort by page" = "ページで並べ替え"
|
||||
"Sort by {column}" = "{column}で並べ替え"
|
||||
"Space around" = "周囲に均等配置"
|
||||
"Space between" = "間に均等配置"
|
||||
"Space drag" = "Space ドラッグ"
|
||||
@@ -1191,9 +1254,11 @@ status = "draft — needs native review"
|
||||
"Start" = "開始"
|
||||
"Start ascending from / stop descending at" = "昇順の開始値 / 降順の終了値"
|
||||
"Start from a blank canvas" = "空白のキャンバスから始める"
|
||||
"Start from a template" = "テンプレートから始める"
|
||||
"Start on path" = "パス上の開始位置"
|
||||
"Start point" = "始点"
|
||||
"Start sharing" = "共有を開始"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える"
|
||||
"Starting to share…" = "共有を開始中…"
|
||||
"Status" = "ステータス"
|
||||
"Sticky" = "固定"
|
||||
@@ -1242,6 +1307,7 @@ status = "draft — needs native review"
|
||||
"Tell the server's operator what is wrong with it" = "何が問題かをサーバーの運営者に伝える"
|
||||
"Temperature" = "色温度"
|
||||
"Template" = "テンプレート"
|
||||
"Templates" = "テンプレート"
|
||||
"Terms" = "利用規約"
|
||||
"Text" = "テキスト"
|
||||
"Text align" = "テキストの配置"
|
||||
@@ -1299,6 +1365,8 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "省略記号で切り詰める"
|
||||
"Try again" = "再試行"
|
||||
"Try another search or filter." = "別の検索語やフィルターを試す。"
|
||||
"Try another word, or look at every kind." = "別の単語で検索するか、すべての種類を表示。"
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "レイヤーをコンポーネントに変換して再利用し、すべてのコピーを一括で変更"
|
||||
"Two digits" = "2 桁"
|
||||
"Type" = "種類"
|
||||
"Type settings" = "文字の設定"
|
||||
@@ -1334,6 +1402,7 @@ status = "draft — needs native review"
|
||||
"Use the layer's fill" = "レイヤーの塗りを使用"
|
||||
"Used in {files} · {instances}" = "{files}で使用 · {instances}"
|
||||
"Username" = "ユーザー名"
|
||||
"Uses variables: each layer shows them in its own mode" = "変数を使用:各レイヤーに設定されたモードで表示"
|
||||
"Value" = "値"
|
||||
"Variable" = "変数"
|
||||
"Variable collections" = "変数コレクション"
|
||||
@@ -1371,12 +1440,14 @@ status = "draft — needs native review"
|
||||
"Web" = "Web"
|
||||
"WebSocket URL" = "WebSocket URL"
|
||||
"WebSocket URL copied" = "WebSocket URL をコピー済み"
|
||||
"Websites" = "Webサイト"
|
||||
"Wedge" = "くさび"
|
||||
"Weight" = "太さ"
|
||||
"What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容"
|
||||
"What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。"
|
||||
"What it's for" = "用途"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。"
|
||||
"What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない"
|
||||
"While hovering" = "ホバー中"
|
||||
"While pressing" = "押している間"
|
||||
"White" = "白"
|
||||
@@ -1386,6 +1457,7 @@ status = "draft — needs native review"
|
||||
"Width" = "幅"
|
||||
"Width point" = "幅のポイント"
|
||||
"Width profile" = "幅プロファイル"
|
||||
"Wireframe kit" = "ワイヤーフレームキット"
|
||||
"With the layers' export settings" = "レイヤーの書き出し設定で"
|
||||
"Wrap" = "折り返し"
|
||||
"Wrap in new section" = "新しいセクションで囲む"
|
||||
@@ -1400,6 +1472,7 @@ status = "draft — needs native review"
|
||||
"Your name in comments" = "コメントに表示する名前"
|
||||
"Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。"
|
||||
"Z" = "Z"
|
||||
"Z to A" = "降順(Z→A)"
|
||||
"Zoom" = "ズーム"
|
||||
"Zoom around the pointer" = "ポインターを中心にズーム"
|
||||
"Zoom device to fill window" = "デバイスをウィンドウいっぱいにズーム"
|
||||
@@ -1426,6 +1499,7 @@ status = "draft — needs native review"
|
||||
"from {server}" = "{server} から"
|
||||
"hidden" = "非表示"
|
||||
"iOS" = "iOS"
|
||||
"just now" = "たった今"
|
||||
"locked" = "ロック中"
|
||||
"no bleed" = "裁ち落としなし"
|
||||
"nobody else here yet" = "まだ他に誰もいない"
|
||||
@@ -1437,9 +1511,11 @@ status = "draft — needs native review"
|
||||
"reads the document" = "ドキュメントを読み取る"
|
||||
"the plugin" = "このプラグイン"
|
||||
"this session only" = "このセッションのみ"
|
||||
"yesterday" = "昨日"
|
||||
"{instances} in {files}" = "{files}内の{instances}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。"
|
||||
"{length} bleed" = "裁ち落とし {length}"
|
||||
"{month} {day}, {year}" = "{year}年{month}{day}日"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nクリックして選択範囲に適用"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nキャンバスにドラッグするか、クリックしてビューの中央に配置。\nインスタンスの上にドロップするときに Alt(Option)を押したままにすると入れ替え。"
|
||||
"{name} (from {library})" = "{name}({library} から)"
|
||||
@@ -1601,8 +1677,10 @@ status = "draft — needs native review"
|
||||
"component.editProperty" = "コンポーネントプロパティを編集"
|
||||
"component.exposeInstance" = "ネストされたインスタンスを公開"
|
||||
"component.goToMain" = "メインコンポーネントへ移動"
|
||||
"component.hideWhenPublishing" = "公開時に非表示"
|
||||
"component.list" = "コンポーネントを一覧表示"
|
||||
"component.makeSlot" = "スロットにする"
|
||||
"component.playground" = "コンポーネントを試す"
|
||||
"component.properties" = "コンポーネントプロパティ"
|
||||
"component.pushChanges" = "変更をメインコンポーネントにプッシュ"
|
||||
"component.removeSlot" = "スロットを削除"
|
||||
@@ -1660,6 +1738,7 @@ status = "draft — needs native review"
|
||||
"file.exportSvg" = "選択範囲を SVG で書き出し"
|
||||
"file.import" = "インポート"
|
||||
"file.new" = "新規デザインファイル"
|
||||
"file.newFromTemplate" = "テンプレートから新規作成"
|
||||
"file.open" = "開く"
|
||||
"file.openRecent" = "最近使ったファイルを開く"
|
||||
"file.placeImage" = "画像を配置"
|
||||
@@ -1687,6 +1766,7 @@ status = "draft — needs native review"
|
||||
"guide.remove" = "ガイドを削除"
|
||||
"guide.select" = "ガイドを選択"
|
||||
"help.about" = "FigCraft について"
|
||||
"help.gettingStarted" = "使い始めるには"
|
||||
"help.page" = "ヘルプページ"
|
||||
"help.shortcuts" = "キーボードショートカット"
|
||||
"image.adjust" = "画像を調整"
|
||||
@@ -1740,6 +1820,8 @@ status = "draft — needs native review"
|
||||
"node.reparent" = "レイヤーを中に移動"
|
||||
"node.set" = "プロパティを設定"
|
||||
"object.batchRename" = "一括で名前を変更"
|
||||
"object.convertToFrame" = "フレームに変換"
|
||||
"object.convertToGroup" = "グループに変換"
|
||||
"object.flipH" = "左右反転"
|
||||
"object.flipV" = "上下反転"
|
||||
"object.frameSelection" = "選択範囲をフレームで囲む"
|
||||
@@ -1855,6 +1937,7 @@ status = "draft — needs native review"
|
||||
"stroke.set" = "線を設定"
|
||||
"stroke.widthProfile" = "幅プロファイル"
|
||||
"style.apply" = "スタイルを適用"
|
||||
"style.bind" = "スタイルに変数を割り当て"
|
||||
"style.create" = "スタイルを作成"
|
||||
"style.delete" = "スタイルを削除"
|
||||
"style.detach" = "スタイルを切り離す"
|
||||
@@ -1863,6 +1946,7 @@ status = "draft — needs native review"
|
||||
"style.move" = "スタイルを移動"
|
||||
"style.renameFolder" = "スタイルフォルダー名を変更"
|
||||
"style.update" = "スタイルを編集"
|
||||
"template.list" = "テンプレートを一覧表示"
|
||||
"text.alignCenter" = "中央揃え"
|
||||
"text.alignJustify" = "両端揃え"
|
||||
"text.alignLeft" = "左揃え"
|
||||
@@ -2028,8 +2112,11 @@ status = "draft — needs native review"
|
||||
"view.nextPage" = "次のページ"
|
||||
"view.nextRegion" = "次の領域にフォーカス"
|
||||
"view.outlines" = "アウトライン"
|
||||
"view.outlinesHiddenLayers" = "アウトラインに非表示のレイヤーを含める"
|
||||
"view.outlinesObjectBounds" = "アウトラインにオブジェクトの境界を含める"
|
||||
"view.outlinesSelection" = "選択範囲のアウトライン"
|
||||
"view.pan" = "ビューをパン"
|
||||
"view.panelWidths" = "パネルの幅をリセット"
|
||||
"view.pixelGrid" = "ピクセルグリッド"
|
||||
"view.pixelPreview" = "ピクセルプレビュー"
|
||||
"view.prevFrame" = "前のフレームにズーム"
|
||||
@@ -2087,6 +2174,8 @@ status = "draft — needs native review"
|
||||
"view.design" = "デザイン"
|
||||
"view.layers" = "レイヤー"
|
||||
"view.outlines" = "アウトラインを表示"
|
||||
"view.outlinesHiddenLayers" = "非表示のレイヤーを含める"
|
||||
"view.outlinesObjectBounds" = "オブジェクトの境界を含める"
|
||||
"view.outlinesSelection" = "選択範囲のアウトラインを表示"
|
||||
"view.prototype" = "プロトタイプ"
|
||||
"view.themeDark" = "ダーク"
|
||||
|
||||
@@ -18,6 +18,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨"
|
||||
"About" = "정보"
|
||||
"About FigCraft" = "FigCraft 정보"
|
||||
|
||||
@@ -16,6 +16,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração"
|
||||
"About" = "Sobre"
|
||||
"About FigCraft" = "Sobre o FigCraft"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定"
|
||||
"About" = "关于"
|
||||
"About FigCraft" = "关于 FigCraft"
|
||||
|
||||
@@ -388,3 +388,18 @@ fn every_accessibility_path_runs_with_the_tree_on_without_nested_context_locks()
|
||||
let out = u.frame_output(vec![]);
|
||||
assert!(out.platform_output.accesskit_update.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() {
|
||||
let (mut u, a, b) = setup();
|
||||
let align_left = |nodes: &[Node]| {
|
||||
nodes.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Align left"))).map(Node::is_disabled).expect("the Align left button")
|
||||
};
|
||||
assert!(align_left(&tree(&mut u)), "Rect A alone on the page: nothing to align to");
|
||||
u.app.run("select.set", json!({"ids": [a, b]}));
|
||||
assert!(!align_left(&tree(&mut u)), "two layers align to each other");
|
||||
let f = id(u.app.session.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let inner = id(u.app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
u.app.run("select.set", json!({"ids": [inner]}));
|
||||
assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it");
|
||||
}
|
||||
|
||||
@@ -190,15 +190,27 @@ fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32) -> Option<egui::TextureHandle> {
|
||||
let b = figcraft_render::export_bounds(doc, id)?;
|
||||
let pix = thumbnail_pixmap(doc, id, px)?;
|
||||
let img = crate::render_worker::screen_image(&pix, doc.color_profile);
|
||||
Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR))
|
||||
}
|
||||
|
||||
/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content
|
||||
/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without
|
||||
/// this a slice's preview is a blank checkerboard next to a correct export.
|
||||
pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option<figcraft_render::Pixmap> {
|
||||
let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? };
|
||||
let side = b.width().max(b.height());
|
||||
if !(side.is_finite() && side > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let scale = (f64::from(px) / side).min(2.0);
|
||||
let pix = figcraft_render::render_node(doc, id, scale)?;
|
||||
let img = crate::render_worker::screen_image(&pix, doc.color_profile);
|
||||
Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR))
|
||||
if slice {
|
||||
let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default();
|
||||
return figcraft_render::render_area(doc, &layers, b, scale);
|
||||
}
|
||||
figcraft_render::render_node(doc, id, scale)
|
||||
}
|
||||
|
||||
fn matches(name: &str, q: &str) -> bool {
|
||||
|
||||
@@ -973,12 +973,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
||||
if app.session.tool.tool == ToolId::Pencil {
|
||||
painter.add(Shape::line(pts, stroke(t.text, 2.0)));
|
||||
} else {
|
||||
for [x0, y0, x1, y1] in &ov.pen_handles {
|
||||
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
|
||||
}
|
||||
for p in &pts {
|
||||
painter.circle_filled(*p, 3.5, Color32::WHITE);
|
||||
painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0));
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = ov.rubber {
|
||||
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0));
|
||||
let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1));
|
||||
// The segment a click would make: along the last anchor's out handle, if it has one.
|
||||
match ov.rubber_handle {
|
||||
Some([hx, hy]) => {
|
||||
let h = app.to_screen(hx, hy);
|
||||
painter.add(egui::epaint::CubicBezierShape::from_points_stroke(
|
||||
[a, h, b, b],
|
||||
false,
|
||||
Color32::TRANSPARENT,
|
||||
stroke(ACCENT, 1.0),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
painter.line_segment([a, b], stroke(ACCENT, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1111,6 +1129,14 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
|
||||
}
|
||||
}
|
||||
|
||||
/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of
|
||||
/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike).
|
||||
fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) {
|
||||
painter.line_segment([v, e], stroke(ACCENT, 1.0));
|
||||
painter.circle_filled(e, r, fill);
|
||||
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
|
||||
}
|
||||
|
||||
/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and
|
||||
/// for the `active` layer the closed area under the paint bucket and the pen's rubber band.
|
||||
fn vector_overlay(
|
||||
@@ -1151,13 +1177,9 @@ fn vector_overlay(
|
||||
return;
|
||||
}
|
||||
for h in &o.handles {
|
||||
let (v, e) = (sp(&h.vertex), sp(&h.handle));
|
||||
painter.line_segment([v, e], stroke(ACCENT, 1.0));
|
||||
let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start });
|
||||
let r = if hovered { 4.5 } else { 3.5 };
|
||||
let on = o.selected_handles.contains(&(h.seg, h.start));
|
||||
painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE });
|
||||
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
|
||||
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE });
|
||||
}
|
||||
for (i, v) in o.vertices.iter().enumerate() {
|
||||
let p = sp(v);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off
|
||||
//! by default), and the render worker counts a job as in flight until it is done.
|
||||
//! by default), the render worker counts a job as in flight until it is done, and the Pen shows
|
||||
//! the handles of the path it draws.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -91,3 +92,36 @@ fn a_job_queued_behind_a_finished_one_stays_in_flight() {
|
||||
// Not in flight only once the second is done: its image is then waiting.
|
||||
assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() {
|
||||
use egui::{Event, PointerButton};
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pen"}));
|
||||
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
|
||||
let press = |p: Pos2, pressed: bool| {
|
||||
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]
|
||||
};
|
||||
let accent = egui::epaint::ColorMode::Solid(crate::theme::ACCENT);
|
||||
// A handle: the line from its anchor and the dot at its end.
|
||||
let handle = |shapes: &[Shape], a: Pos2, e: Pos2| {
|
||||
let near = |p: Pos2, q: Pos2| p.distance(q) < 0.5;
|
||||
let line = shapes.iter().any(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if near(*p, a) && near(*q, e)));
|
||||
let dot = shapes.iter().any(|s| matches!(s, Shape::Circle(c) if near(c.center, e) && c.fill == egui::Color32::WHITE));
|
||||
line && dot
|
||||
};
|
||||
let a = at(&u, 100.0, 100.0);
|
||||
u.frame(press(a, true));
|
||||
for k in 1..=4 {
|
||||
u.frame(vec![Event::PointerMoved(at(&u, 100.0 + 10.0 * f64::from(k), 100.0))]);
|
||||
}
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the out handle while dragging");
|
||||
assert!(handle(&shapes, a, at(&u, 60.0, 100.0)), "and the in handle opposite");
|
||||
u.frame(press(at(&u, 140.0, 100.0), false));
|
||||
// After release, towards the next point: the band curves along the out handle.
|
||||
let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at(&u, 200.0, 200.0))]).shapes);
|
||||
assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the handles stay");
|
||||
let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent));
|
||||
assert!(band, "a curve leaving along the out handle");
|
||||
}
|
||||
|
||||
@@ -438,10 +438,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
) {
|
||||
let kinds = [
|
||||
(Icon::Fill, tr("Solid")),
|
||||
(Icon::LayoutH, tr("Linear")),
|
||||
(Icon::Ellipse, tr("Radial")),
|
||||
(Icon::Rotate, tr("Angular")),
|
||||
(Icon::Instance, tr("Diamond")),
|
||||
(Icon::GradientLinear, tr("Linear")),
|
||||
(Icon::GradientRadial, tr("Radial")),
|
||||
(Icon::GradientAngular, tr("Angular")),
|
||||
(Icon::GradientDiamond, tr("Diamond")),
|
||||
(Icon::Image, tr("Image")),
|
||||
(Icon::ViewGrid, tr("Pattern")),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The Export section's Preview row: a slice paints nothing itself, so its preview must show the
|
||||
//! page content under it — the same thing exporting the slice writes — and not a blank
|
||||
//! transparency checkerboard next to a correct export.
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::assets_panel::thumbnail_pixmap;
|
||||
|
||||
/// Run `commands`, returning the document and the id each one made.
|
||||
fn doc_after(commands: &[(&str, Value)]) -> (Document, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
let ids = commands.iter().map(|(cmd, params)| NodeId(s.execute(cmd, params).unwrap()["id"].as_u64().unwrap())).collect();
|
||||
(s.doc().unwrap().clone(), ids)
|
||||
}
|
||||
|
||||
/// Whether the pixmap paints anything (any pixel not fully transparent).
|
||||
fn paints(pix: &figcraft_render::Pixmap) -> bool {
|
||||
figcraft_render::to_rgba8(pix).as_chunks::<4>().0.iter().any(|p| p.get(3).copied().unwrap_or(0) > 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slice_previews_the_content_under_it() {
|
||||
let (doc, ids) = doc_after(&[
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})),
|
||||
("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100})),
|
||||
]);
|
||||
let slice = ids[1];
|
||||
let pix = thumbnail_pixmap(&doc, slice, 200.0).expect("the slice previews");
|
||||
assert_eq!([pix.width(), pix.height()], [200, 100], "the slice's area, not an empty node");
|
||||
assert!(paints(&pix), "the rectangle under the slice shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slice_with_nothing_under_it_is_blank() {
|
||||
let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]);
|
||||
let pix = thumbnail_pixmap(&doc, ids[0], 200.0).expect("the preview still has the slice's area");
|
||||
assert_eq!([pix.width(), pix.height()], [200, 100]);
|
||||
assert!(!paints(&pix), "nothing under it stays transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_layers_preview_themselves() {
|
||||
let (doc, ids) = doc_after(&[
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})),
|
||||
("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100})),
|
||||
]);
|
||||
let pix = thumbnail_pixmap(&doc, ids[0], 200.0).expect("the rectangle previews");
|
||||
assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area");
|
||||
assert!(paints(&pix));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The control channel takes the documented gradient shorthand, as MCP does (#252).
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
#[test]
|
||||
fn engine_execute_takes_the_gradient_shorthand() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let exec = |u: &mut Ui, command: &str, params: serde_json::Value| {
|
||||
crate::control::handle(&mut u.app, &u.ctx.clone(), "engine.execute", &json!({"command": command, "params": params}))
|
||||
};
|
||||
let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone();
|
||||
let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]});
|
||||
exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap();
|
||||
let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap();
|
||||
let f = &n["fills"][0];
|
||||
assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}");
|
||||
assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}");
|
||||
}
|
||||
@@ -88,26 +88,49 @@ pub(crate) struct FileRow<'a> {
|
||||
pub detail_ink: Color32,
|
||||
}
|
||||
|
||||
/// Paint `row` in `r` (highlighted while `resp` is hovered): the name gives way to the detail.
|
||||
pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) {
|
||||
/// Paint `row` in `r` (highlighted while `resp` is hovered): the name first, the detail in the
|
||||
/// room left after it, cut at its start; true when either is cut.
|
||||
pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) -> bool {
|
||||
/// Between the name and the detail.
|
||||
const GAP: f32 = 16.0;
|
||||
/// A detail with less room than this is left out rather than shown as a bare ellipsis.
|
||||
const MIN_DETAIL: f32 = 48.0;
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
if let Some((icon, tint)) = row.mark {
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, tint);
|
||||
}
|
||||
let detail = ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, row.detail, regular(row.detail_size), row.detail_ink);
|
||||
let name = widgets::elide_font(ui, row.name, (detail.min.x - r.min.x - 44.0).max(40.0), regular(12.0));
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), row.ink);
|
||||
let (name_font, detail_font) = (regular(12.0), regular(row.detail_size));
|
||||
let width = |s: &str, font: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x;
|
||||
let room = (r.width() - 40.0).max(0.0);
|
||||
let name_w = width(row.name, &name_font);
|
||||
let detail_w = width(row.detail, &detail_font);
|
||||
// When both don't fit, the name keeps at least 60% of the row and the detail gets the rest.
|
||||
let name_max = if name_w + GAP + detail_w <= room { name_w } else { name_w.min((room - GAP - detail_w).max(room * 0.6)) };
|
||||
let name = widgets::elide_font(ui, row.name, name_max, name_font.clone());
|
||||
let detail_room = room - width(&name, &name_font) - GAP;
|
||||
let detail = if row.detail.is_empty() || detail_room < MIN_DETAIL {
|
||||
String::new()
|
||||
} else {
|
||||
widgets::elide_start_font(ui, row.detail, detail_room, detail_font.clone())
|
||||
};
|
||||
let cut = name != row.name || detail != row.detail;
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, name_font, row.ink);
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, detail, detail_font, row.detail_ink);
|
||||
cut
|
||||
}
|
||||
|
||||
/// A row of Recents' own lists: laid out in place, named for screen readers, ringed with the
|
||||
/// keyboard on it; true when chosen.
|
||||
fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), height), Sense::hover());
|
||||
let resp = ui.interact(r, id, Sense::click());
|
||||
let mut resp = ui.interact(r, id, Sense::click());
|
||||
a11y::button(&resp, row.name);
|
||||
file_row(ui, t, r, &resp, row);
|
||||
if file_row(ui, t, r, &resp, row) {
|
||||
// Cut to fit: in full on hover.
|
||||
resp = resp.on_hover_text(format!("{}\n{}", row.name, row.detail));
|
||||
}
|
||||
focus_ring(ui, t, r, &resp, 5);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
@@ -319,8 +319,16 @@ pub struct Recents {
|
||||
pub selected: Option<String>,
|
||||
/// The pass the recent files were last drawn in: coming back to them reads the dates again.
|
||||
drawn: u64,
|
||||
/// When the dates were last read again while in view, and whether the window had the focus:
|
||||
/// files deleted, moved or saved elsewhere show up within [`RECHECK_SECS`], or at once when
|
||||
/// the window gets the focus back.
|
||||
checked: f64,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
/// How often the dates of the recent files in view are read again (seconds).
|
||||
const RECHECK_SECS: f64 = 2.0;
|
||||
|
||||
enum Job {
|
||||
Stat(String),
|
||||
Preview(String, Option<u64>),
|
||||
@@ -423,10 +431,15 @@ impl Recents {
|
||||
/// files not looked at since the recent files came into view. `time`: the input time.
|
||||
fn refresh(&mut self, ctx: &egui::Context, paths: &[String], time: f64) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
if pass > self.drawn.saturating_add(1) {
|
||||
let (focused, refocused) = ctx.input(|i| (i.focused, i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(true)))));
|
||||
let back = pass > self.drawn.saturating_add(1) || refocused || (focused && !self.focused);
|
||||
if back || time - self.checked >= RECHECK_SECS {
|
||||
self.files.values_mut().for_each(|k| k.asked = false);
|
||||
self.checked = time;
|
||||
}
|
||||
self.drawn = pass;
|
||||
self.focused = focused;
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECHECK_SECS));
|
||||
let loader = self.loader.get_or_insert_with(|| Loader::new(ctx));
|
||||
for reply in loader.poll() {
|
||||
match reply {
|
||||
|
||||
@@ -533,3 +533,38 @@ fn the_home_screen_draws_no_frames_once_the_pictures_are_in() {
|
||||
assert!(delay >= Duration::from_secs(1), "a frame in {delay:?}: {:?}", h.ctx.repaint_causes());
|
||||
assert_eq!(file(&h.app.inspect()["homeRecents"], "pic")["picture"], "picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_deleted_while_in_view_show_as_gone() {
|
||||
let (_, paths) = files("deleted");
|
||||
let (mut h, _) = crate::live_tests::Harness::new();
|
||||
h.app.session.prefs.recent_files = paths.clone();
|
||||
h.app.session.prefs.getting_started_dismissed = true;
|
||||
h.app.ui.home = true;
|
||||
let exists = |h: &mut crate::live_tests::Harness, name: &str| file(&h.app.inspect()["homeRecents"], name)["exists"].clone();
|
||||
// Up to `n` frames (1/60 s of UI time each), giving the reader thread time between them.
|
||||
let wait_n = |h: &mut crate::live_tests::Harness, name: &str, want: bool, n: usize| {
|
||||
for _ in 0..n {
|
||||
h.frame(vec![]);
|
||||
if exists(h, name) == want {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
};
|
||||
let wait = |h: &mut crate::live_tests::Harness, name: &str, want: bool| wait_n(h, name, want, 400);
|
||||
assert!(wait(&mut h, "pic", true) && wait(&mut h, "art", true));
|
||||
// Deleted while the recent files stay in view: within a couple of seconds.
|
||||
std::fs::remove_file(&paths[0]).unwrap();
|
||||
h.frames(5);
|
||||
assert_eq!(exists(&mut h, "pic"), true, "not read again on every frame");
|
||||
h.skip(2.5);
|
||||
assert!(wait(&mut h, "pic", false), "read again after a while");
|
||||
// Deleted while the window was in the background: as soon as it has the focus again.
|
||||
std::fs::remove_file(&paths[2]).unwrap();
|
||||
h.frame(vec![egui::Event::WindowFocused(false)]);
|
||||
h.frame(vec![egui::Event::WindowFocused(true)]);
|
||||
// Well under the regular interval, so only the focus can have caused it.
|
||||
assert!(wait_n(&mut h, "art", false, 30), "read again on getting the focus back");
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
//! started card (closed, kept closed, and brought back from the Help menu).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape};
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::templates::TEMPLATES;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::home::{Section, cached_thumbnails};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, has_text, text_center};
|
||||
use crate::live_tests::{flat, has_text, text_center, texts};
|
||||
|
||||
/// The app as a first run finds it: no documents, no recent files.
|
||||
fn first_run() -> Ui {
|
||||
@@ -22,11 +22,22 @@ fn shapes(u: &mut Ui) -> Vec<Shape> {
|
||||
flat(&u.frame_output(vec![]).shapes)
|
||||
}
|
||||
|
||||
/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click.
|
||||
/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click,
|
||||
/// then until every recent file has been looked at: the reader is a thread of its own, and until it
|
||||
/// answers a file's card is a skeleton without its name (on a loaded machine, 12 frames weren't
|
||||
/// always enough, #351).
|
||||
fn settle(u: &mut Ui) -> Vec<Shape> {
|
||||
for _ in 0..12 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
for _ in 0..400 {
|
||||
let files = u.app.inspect()["homeRecents"]["files"].as_array().cloned().unwrap_or_default();
|
||||
if files.iter().all(|f| !f["exists"].is_null()) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
u.frame(vec![]);
|
||||
}
|
||||
shapes(u)
|
||||
}
|
||||
|
||||
@@ -222,6 +233,42 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() {
|
||||
assert!(has_text(&s, "Open files"), "the document opened from the template is listed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_files_long_name_and_path_share_their_row_without_overlapping() {
|
||||
let name = "Quarterly planning: onboarding flow review with annotations and handoff notes";
|
||||
let long = "/Users/alex/Documents/Clients/Northwind Traders/2026 Website relaunch/Design reviews/Week 42/onboarding-flow-review.figcraft";
|
||||
let short = "/tmp/wireframes.figcraft";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.rename", &json!({"name": name})).unwrap();
|
||||
s.docs[0].path = Some(long.into());
|
||||
s.execute("file.new", &json!({"name": "Wireframes"})).unwrap();
|
||||
s.docs[1].path = Some(short.into());
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.home = true;
|
||||
u.app.session.prefs.getting_started_dismissed = true;
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Open files"));
|
||||
// A row that fits shows its name and path in full.
|
||||
assert!(has_text(&s, "Wireframes") && has_text(&s, short));
|
||||
// One that doesn't: the name, then the end of its path, side by side.
|
||||
let all = texts(&s);
|
||||
let rect = |(_, p, size): &(String, Pos2, egui::Vec2)| Rect::from_min_size(*p, *size);
|
||||
let detail = all.iter().find(|(t, _, _)| t.ends_with("/onboarding-flow-review.figcraft")).expect("the path");
|
||||
let detail_rect = rect(detail);
|
||||
let shown_name =
|
||||
all.iter().find(|e| e.0.starts_with('Q') && (rect(e).center().y - detail_rect.center().y).abs() < 2.0).expect("the name on the path's row");
|
||||
let name_rect = rect(shown_name);
|
||||
assert!(shown_name.0.starts_with("Quarterly planning: onboarding") && shown_name.0.ends_with('…'), "{:?}", shown_name.0);
|
||||
assert!(name_rect.max.x + 12.0 <= detail_rect.min.x, "{:?} {name_rect:?} overlaps {:?} {detail_rect:?}", shown_name.0, detail.0);
|
||||
assert!(detail.0.starts_with('…') && long.ends_with(detail.0.trim_start_matches('…')), "{:?}", detail.0);
|
||||
// Hovered, the row shows them in full.
|
||||
u.frame(vec![Event::PointerMoved(name_rect.center())]);
|
||||
for _ in 0..60 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(long)), "no tooltip with the full path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_getting_started_card_closes_stays_closed_and_comes_back_from_help() {
|
||||
let mut u = first_run();
|
||||
|
||||
@@ -120,6 +120,11 @@ icons! {
|
||||
Export,
|
||||
StrokeIcon,
|
||||
Fill,
|
||||
/// Gradient kinds: a swatch outline holding a fade of that kind.
|
||||
GradientLinear,
|
||||
GradientRadial,
|
||||
GradientAngular,
|
||||
GradientDiamond,
|
||||
Sun,
|
||||
Moon,
|
||||
Undo,
|
||||
@@ -311,6 +316,32 @@ impl P<'_> {
|
||||
// A hairline around the mesh gives it the same soft edge as the convex fills.
|
||||
self.painter.add(Shape::closed_line(v, Stroke::new(self.px, self.color)));
|
||||
}
|
||||
/// Triangles `tris` over `verts` (x, y, alpha): the colour fades between the vertices.
|
||||
fn shade(&self, verts: &[(f32, f32, f32)], tris: &[u32]) {
|
||||
let mut mesh = Mesh::default();
|
||||
for &(x, y, a) in verts {
|
||||
mesh.colored_vertex(self.raw(x, y), self.color.gamma_multiply(a));
|
||||
}
|
||||
mesh.indices = tris.to_vec();
|
||||
self.painter.add(Shape::mesh(mesh));
|
||||
}
|
||||
/// A disc of radius `r` at the centre, as a fan from the top, clockwise: each slice's rim
|
||||
/// alphas from `rim` and its centre's from `centre`, both of how far round it is (0..1).
|
||||
fn shade_fan(&self, r: f32, centre: impl Fn(f32) -> f32, rim: impl Fn(f32) -> f32) {
|
||||
const SLICES: u32 = 32;
|
||||
let at = |t: f32| {
|
||||
let a = -FRAC_PI_2 + t * TAU;
|
||||
(8.0 + r * a.cos(), 8.0 + r * a.sin())
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
for i in 0..SLICES {
|
||||
let (t0, t1) = (i as f32 / SLICES as f32, (i + 1) as f32 / SLICES as f32);
|
||||
let ((x0, y0), (x1, y1)) = (at(t0), at(t1));
|
||||
verts.extend([(8.0, 8.0, centre((t0 + t1) / 2.0)), (x0, y0, rim(t0)), (x1, y1, rim(t1))]);
|
||||
}
|
||||
let tris: Vec<u32> = (0..SLICES * 3).collect();
|
||||
self.shade(&verts, &tris);
|
||||
}
|
||||
fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) {
|
||||
let rr = Rect::from_min_max(self.sp(x0, y0), self.sp(x1, y1));
|
||||
self.painter.rect_stroke(rr, egui::CornerRadius::same((r * self.s).round() as u8), self.stroke, egui::StrokeKind::Middle);
|
||||
@@ -833,6 +864,24 @@ fn draw(p: &P, icon: Icon) {
|
||||
}
|
||||
StrokeIcon => p.rect(2.5, 2.5, 13.5, 13.5, 2.0),
|
||||
Fill => p.fill_rect(2.0, 2.0, 14.0, 14.0, 2.0),
|
||||
GradientLinear => {
|
||||
p.rect(2.5, 2.5, 13.5, 13.5, 2.0);
|
||||
p.shade(&[(4.0, 4.0, 1.0), (12.0, 4.0, 0.0), (12.0, 12.0, 0.0), (4.0, 12.0, 1.0)], &[0, 1, 2, 0, 2, 3]);
|
||||
}
|
||||
GradientRadial => {
|
||||
p.rect(2.5, 2.5, 13.5, 13.5, 2.0);
|
||||
p.shade_fan(5.5, |_| 1.0, |_| 0.0);
|
||||
}
|
||||
GradientAngular => {
|
||||
// Once round from the top: the seam shows where the sweep starts.
|
||||
p.rect(2.5, 2.5, 13.5, 13.5, 2.0);
|
||||
p.shade_fan(4.0, |t| 1.0 - t, |t| 1.0 - t);
|
||||
}
|
||||
GradientDiamond => {
|
||||
p.rect(2.5, 2.5, 13.5, 13.5, 2.0);
|
||||
let v = [(8.0, 8.0, 1.0), (8.0, 2.5, 0.0), (13.5, 8.0, 0.0), (8.0, 13.5, 0.0), (2.5, 8.0, 0.0)];
|
||||
p.shade(&v, &[0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]);
|
||||
}
|
||||
Moon => {
|
||||
// A crescent: the outer rim, then the shadowing circle's edge back. Both arcs stop short
|
||||
// of where the circles meet, so each tip is two blunt joins (a single cusp join folds).
|
||||
@@ -1102,6 +1151,28 @@ mod tests {
|
||||
assert!(out.shapes.len() > Icon::ALL.len() * 10);
|
||||
}
|
||||
|
||||
/// The gradient kinds' icons show a fade: a mesh whose vertices run from opaque to clear.
|
||||
#[test]
|
||||
fn gradient_icons_fade() {
|
||||
let ctx = egui::Context::default();
|
||||
for icon in [Icon::GradientLinear, Icon::GradientRadial, Icon::GradientAngular, Icon::GradientDiamond] {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(16.0)), icon, Color32::WHITE);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let alphas: Vec<u8> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
Shape::Mesh(m) => Some(m.vertices.iter().map(|v| v.color.a()).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
assert!(alphas.contains(&255) && alphas.contains(&0), "{icon:?} fades from opaque to clear: {alphas:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_polygons_triangulate() {
|
||||
let arrow = [pos2(4.0, 2.0), pos2(4.0, 13.6), pos2(6.9, 10.9), pos2(8.8, 14.8), pos2(10.6, 14.0), pos2(8.7, 10.2), pos2(12.6, 10.2)];
|
||||
|
||||
@@ -241,9 +241,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
if renaming {
|
||||
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
|
||||
if let Some(new) =
|
||||
crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) })
|
||||
{
|
||||
let start = ui.data_mut(|d| d.remove_temp::<bool>(page_rename_start_id())).unwrap_or(false);
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) {
|
||||
rename_done = Some((id, new));
|
||||
}
|
||||
} else if !divider {
|
||||
@@ -255,7 +255,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
app.ui.rename_page = Some((id, name.clone()));
|
||||
ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te")));
|
||||
// The field takes the focus next frame, once it's drawn: focusing it now would put a
|
||||
// node that isn't there yet in the screen reader tree, which AccessKit aborts on.
|
||||
ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true));
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if ui.button(tr("Rename page")).clicked() {
|
||||
@@ -295,6 +297,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
crate::widgets::separator(ui);
|
||||
}
|
||||
|
||||
/// A page rename just asked for by a double click: its field takes the focus when drawn (temp data).
|
||||
fn page_rename_start_id() -> Id {
|
||||
Id::new("page_rename_start")
|
||||
}
|
||||
|
||||
/// The layer row being dragged in the panel (temp data).
|
||||
fn layer_drag_id() -> Id {
|
||||
Id::new("layer_drag")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! list (dragging pages, dividers). Rows the documentation is silent on say so and pin today's
|
||||
//! behaviour. What the gestures do to the document is in the engine's `panels_behaviour_tests`.
|
||||
|
||||
use egui::{Event, Modifiers, Pos2};
|
||||
use egui::{Event, Id, Modifiers, Pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -226,3 +226,44 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() {
|
||||
assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
/// A double click at `p`, with the screen reader tree checked on every frame: the platform adapter
|
||||
/// (UI Automation on Windows) aborts the app when the focused node isn't in the tree.
|
||||
fn double_click_checking_focus(u: &mut Ui, p: Pos2) {
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
}
|
||||
let presses = [true, false, true, false].into_iter().map(Some);
|
||||
for (k, pressed) in presses.chain(std::iter::repeat_n(None, 30)).enumerate() {
|
||||
let events =
|
||||
pressed.map(|pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
let out = u.frame_output(events.into_iter().collect());
|
||||
let tree = out.platform_output.accesskit_update.expect("an AccessKit update");
|
||||
assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() {
|
||||
let mut s = Session::new();
|
||||
let p1 = s.page().unwrap();
|
||||
let p2 = id(s.execute("page.add", &json!({})).unwrap());
|
||||
let mut u = Ui::new(s);
|
||||
let (r1, r2) = (page_row(&mut u, p1, p2), page_row(&mut u, p2, p1));
|
||||
u.ctx.enable_accesskit();
|
||||
// Another page: the first click opens it, the second renames it.
|
||||
double_click_checking_focus(&mut u, r2);
|
||||
assert_eq!(u.app.session.page().unwrap(), p2);
|
||||
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page");
|
||||
assert!(u.ctx.memory(|m| m.has_focus(Id::new(("pagename", p2)).with("te"))), "with the keyboard in its name");
|
||||
// The page that's open.
|
||||
u.app.ui.rename_page = None;
|
||||
u.ctx.memory_mut(|m| m.surrender_focus(Id::new(("pagename", p2)).with("te")));
|
||||
u.app.session.execute("page.select", &json!({"id": p1})).unwrap();
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
double_click_checking_focus(&mut u, r1);
|
||||
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1));
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
@@ -100,9 +100,12 @@ fn tab_on_the_last_row_commits_and_ends() {
|
||||
|
||||
#[test]
|
||||
fn cmd_r_renames_the_selection_and_opens_its_parents() {
|
||||
// Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`.
|
||||
// Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. On macOS Ctrl
|
||||
// comes without `command` and Ctrl+R isn't a shortcut.
|
||||
let cmd = Modifiers::MAC_CMD | Modifiers::COMMAND;
|
||||
let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() };
|
||||
for (k, held) in [Modifiers::MAC_CMD | Modifiers::COMMAND, ctrl].into_iter().enumerate() {
|
||||
let keys: &[Modifiers] = if cfg!(target_os = "macos") { &[cmd] } else { &[cmd, ctrl] };
|
||||
for (k, &held) in keys.iter().enumerate() {
|
||||
let (mut u, _, frame, child, _) = setup();
|
||||
u.app.session.execute("select.set", &json!({"ids": [child]})).unwrap();
|
||||
u.frame(vec![]);
|
||||
|
||||
@@ -63,6 +63,9 @@ pub mod versions;
|
||||
pub mod widgets;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod theme_startup_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod a11y_tests;
|
||||
#[cfg(test)]
|
||||
@@ -78,12 +81,16 @@ mod cursors_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod export_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod field_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_label_tests;
|
||||
#[cfg(test)]
|
||||
mod gap_handle_tests;
|
||||
#[cfg(test)]
|
||||
mod gradient_shorthand_tests;
|
||||
#[cfg(test)]
|
||||
mod home_recents_tests;
|
||||
#[cfg(test)]
|
||||
mod home_tests;
|
||||
@@ -144,12 +151,18 @@ mod shortcut_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcuts_tests;
|
||||
#[cfg(test)]
|
||||
mod style_folder_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod text_enter_tests;
|
||||
#[cfg(test)]
|
||||
mod titlebar_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_tail_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod web_clipboard_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_qa_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_tests;
|
||||
@@ -213,6 +226,23 @@ pub trait Services {
|
||||
pub struct NoServices;
|
||||
impl Services for NoServices {}
|
||||
|
||||
/// Put `text` on the system clipboard through egui's output, which the browser shell writes while
|
||||
/// it handles the copy event (the only time a page may write it). For shells without a clipboard
|
||||
/// of their own.
|
||||
pub fn egui_copy_text(ctx: &egui::Context, text: &str) {
|
||||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
|
||||
/// Put a straight-RGBA image on the system clipboard through egui's output (see
|
||||
/// [`egui_copy_text`]); a buffer that doesn't match its size is dropped.
|
||||
pub fn egui_copy_png(ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) {
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
if w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
|
||||
return;
|
||||
}
|
||||
ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum RightTab {
|
||||
Design,
|
||||
@@ -888,11 +918,13 @@ impl FigApp {
|
||||
}
|
||||
let dark = match self.session.prefs.theme.as_str() {
|
||||
"dark" => true,
|
||||
"system" => ctx.global_style().visuals.dark_mode,
|
||||
// The active visuals were set by us last frame (possibly for a manual theme).
|
||||
// Read the integration's OS theme instead; without one, keep the light fallback.
|
||||
"system" => ctx.system_theme() == Some(egui::Theme::Dark),
|
||||
_ => false,
|
||||
};
|
||||
self.ui.dark = dark;
|
||||
// New files and pages take the theme's page colour (existing pages keep theirs).
|
||||
// New files take the theme's page colour (existing pages keep theirs).
|
||||
self.session.set_ui_dark(dark);
|
||||
theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion());
|
||||
if self.worker.is_none() {
|
||||
|
||||
@@ -599,12 +599,15 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
(Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])),
|
||||
];
|
||||
let w = col / 3.0;
|
||||
if let Some(i) = widgets::segmented(ui, &items[..3], None, w) {
|
||||
let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| {
|
||||
let h = widgets::segmented(ui, &items[..3], None, w);
|
||||
let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3);
|
||||
h.or(v)
|
||||
});
|
||||
picked.response.on_disabled_hover_text(tr("A layer on its own aligns to its frame: select more layers, or one in a frame"));
|
||||
if let Some(i) = picked.inner {
|
||||
app.run(cmds[i], json!({"ids": ids}));
|
||||
}
|
||||
if let Some(i) = widgets::segmented(ui, &items[3..], None, w) {
|
||||
app.run(cmds[i + 3], json!({"ids": ids}));
|
||||
}
|
||||
if sel.len() > 2 {
|
||||
let more = icons::button(ui, Icon::DistributeH, 24.0, false, tr("Distribute and tidy"));
|
||||
menu_popup(&more).show(|ui| {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Renaming a folder of local styles, drawn headless with the screen reader tree on: Rename folder
|
||||
//! in the folder's menu opens its name for editing. On every frame the focused node must be in the
|
||||
//! AccessKit tree; the platform adapter (UI Automation on Windows) aborts the app when it isn't
|
||||
//! (the same fault as #224).
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
|
||||
/// One frame with `events`, checking the focused node is in the screen reader tree.
|
||||
fn checked_frame(u: &mut Ui, events: Vec<Event>, k: usize) -> Vec<egui::Shape> {
|
||||
let out = u.frame_output(events);
|
||||
let tree = out.platform_output.accesskit_update.expect("an AccessKit update");
|
||||
assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus);
|
||||
flat(&out.shapes)
|
||||
}
|
||||
|
||||
/// A click with `button` at `p` and the frames that follow, each checked.
|
||||
fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec<egui::Shape> {
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
}
|
||||
let mut shapes = Vec::new();
|
||||
for (k, pressed) in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)).enumerate() {
|
||||
let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE });
|
||||
shapes = checked_frame(u, events.into_iter().collect(), k);
|
||||
}
|
||||
shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_style_folder_keeps_the_focus_in_the_screen_reader_tree() {
|
||||
let mut s = Session::new();
|
||||
for name in ["Brand/Primary", "Brand/Accent"] {
|
||||
s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap();
|
||||
}
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.enable_accesskit();
|
||||
let shapes = checked_frame(&mut u, vec![], 0);
|
||||
// The Design panel lists the local styles with nothing selected; the folder's menu renames it.
|
||||
let menu = click(&mut u, text_center(&shapes, "Brand"), PointerButton::Secondary);
|
||||
click(&mut u, text_center(&menu, "Rename folder"), PointerButton::Primary);
|
||||
checked_frame(&mut u, vec![Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }], 0);
|
||||
checked_frame(&mut u, vec![Event::Text("Theme".into())], 1);
|
||||
for pressed in [true, false] {
|
||||
checked_frame(&mut u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 2);
|
||||
}
|
||||
for k in 0..5 {
|
||||
checked_frame(&mut u, vec![], k + 3);
|
||||
}
|
||||
let names: Vec<String> = u.app.session.doc().unwrap().styles.iter().map(|s| s.name.clone()).collect();
|
||||
assert_eq!(names, ["Theme/Primary", "Theme/Accent"]);
|
||||
}
|
||||
@@ -1042,7 +1042,10 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
|
||||
let text_r = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y + 2.0), pos2(r.max.x - widgets::PAD, r.max.y - 2.0));
|
||||
if renaming.as_deref() == Some(*f) {
|
||||
let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), false);
|
||||
// Focused as it is first drawn: a focus asked for before its field is drawn
|
||||
// isn't in the screen reader tree (UI Automation aborts the app, #224).
|
||||
let start = ui.data_mut(|d| d.remove_temp::<bool>(folder_rename_id().with("start"))).unwrap_or(false);
|
||||
let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), start);
|
||||
if let Some(name) = out {
|
||||
run = Some(("style.renameFolder", json!({"folder": f, "name": name})));
|
||||
}
|
||||
@@ -1055,7 +1058,7 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Rename folder"), "") {
|
||||
ui.data_mut(|d| d.insert_temp(folder_rename_id(), f.to_string()));
|
||||
ui.memory_mut(|m| m.request_focus(folder_rename_id().with(*f).with("te")));
|
||||
ui.data_mut(|d| d.insert_temp(folder_rename_id().with("start"), true));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,5 +75,7 @@ fn a_new_file_takes_the_page_colour_of_the_theme_and_keeps_it() {
|
||||
u.app.run("view.themeDark", json!({}));
|
||||
u.frame(vec![]);
|
||||
u.app.run("page.add", json!({}));
|
||||
assert_eq!(page_colour(&u), Color::DARK_PAGE, "a page added in the dark theme");
|
||||
assert_eq!(page_colour(&u), Color::DEFAULT_PAGE, "a page added in the dark theme follows its light page");
|
||||
u.app.run("file.new", json!({}));
|
||||
assert_eq!(page_colour(&u), Color::DARK_PAGE, "a file made in the dark theme");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Startup and system-theme changes through the real UI frame, before any settling frames.
|
||||
|
||||
use egui::{Context, RawInput, Theme};
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, NoServices, theme::Tokens};
|
||||
|
||||
fn frame(ctx: &Context, app: &mut FigApp, system: Option<Theme>) {
|
||||
let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui));
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_app_uses_system_dark_theme_on_its_first_frame() {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
frame(&ctx, &mut app, Some(Theme::Dark));
|
||||
assert!(app.ui.dark, "a first run must use the OS dark theme before drawing");
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::dark());
|
||||
assert_eq!(app.session.prefs.theme, "system", "following the OS must remain a preference, not a saved snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_and_preferences_without_a_theme_follow_system_light() {
|
||||
for prefs in [Prefs::default(), serde_json::from_value(json!({"nudge_small": 2})).unwrap()] {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::empty();
|
||||
session.prefs = prefs;
|
||||
let mut app = FigApp::new(session, Box::new(NoServices));
|
||||
frame(&ctx, &mut app, Some(Theme::Light));
|
||||
assert_eq!(app.session.prefs.theme, "system");
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::light());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_manual_themes_survive_startup_and_os_changes() {
|
||||
for (saved, expected) in [("light", Tokens::light()), ("dark", Tokens::dark())] {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::empty();
|
||||
session.prefs = serde_json::from_value(json!({"theme": saved})).unwrap();
|
||||
let mut app = FigApp::new(session, Box::new(NoServices));
|
||||
for system in [Theme::Dark, Theme::Light, Theme::Dark] {
|
||||
frame(&ctx, &mut app, Some(system));
|
||||
assert_eq!(Tokens::get(&ctx), expected);
|
||||
assert_eq!(app.session.prefs.theme, saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_system_reads_the_os_not_the_previously_drawn_palette() {
|
||||
for (manual, os) in [("view.themeLight", Theme::Dark), ("view.themeDark", Theme::Light)] {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
app.session.execute(manual, &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, Some(os));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, Some(os));
|
||||
assert_eq!(app.ui.dark, os == Theme::Dark);
|
||||
assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_theme_changes_apply_in_the_same_frame() {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
for os in [Theme::Dark, Theme::Light, Theme::Dark] {
|
||||
frame(&ctx, &mut app, Some(os));
|
||||
assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark);
|
||||
assert_eq!(app.session.prefs.theme, "system");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_system_theme_uses_the_existing_light_fallback() {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, None);
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::light());
|
||||
}
|
||||
@@ -825,7 +825,11 @@ fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usiz
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let col = if active == Some(i) { t.text } else { t.text_secondary };
|
||||
let galley = ui.painter().layout_no_wrap((*label).to_string(), regular(11.5), col);
|
||||
let galley = if *tip == "Small caps" {
|
||||
small_caps_sample(ui, label, col)
|
||||
} else {
|
||||
ui.painter().layout_no_wrap((*label).to_string(), regular(11.5), col)
|
||||
};
|
||||
let gr = Rect::from_center_size(r.center(), galley.size());
|
||||
let size = galley.size();
|
||||
ui.painter().galley(gr.min, galley, col);
|
||||
@@ -848,6 +852,33 @@ fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usiz
|
||||
out
|
||||
}
|
||||
|
||||
/// The Small caps sample: a capital, then its other letters as smaller capitals. Drawn from
|
||||
/// ordinary capitals because the small-capital letters (`ɢ`, U+0262) are missing from many UI
|
||||
/// fonts, egui's defaults among them, where they showed as a box.
|
||||
fn small_caps_sample(ui: &Ui, label: &str, col: Color32) -> Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let mut chars = label.chars();
|
||||
let first: String = chars.next().into_iter().collect();
|
||||
let rest: String = chars.map(small_capital_to_capital).collect::<String>().to_uppercase();
|
||||
job.append(&first, 0.0, egui::TextFormat { font_id: regular(11.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() });
|
||||
job.append(&rest, 0.0, egui::TextFormat { font_id: regular(8.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() });
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// The capital a small-capital letter stands for (the ones a sample uses), else the letter.
|
||||
fn small_capital_to_capital(c: char) -> char {
|
||||
match c {
|
||||
'ɢ' => 'G',
|
||||
'ʜ' => 'H',
|
||||
'ɪ' => 'I',
|
||||
'ʟ' => 'L',
|
||||
'ɴ' => 'N',
|
||||
'ʀ' => 'R',
|
||||
'ʏ' => 'Y',
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// One OpenType feature toggle; `on` None = mixed. Returns true when clicked.
|
||||
fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option<bool>, supported: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -1136,6 +1167,29 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn small_caps_sample_draws_only_letters_the_ui_font_has() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, false, 0);
|
||||
let mut shown = None;
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
shown = Some(small_caps_sample(ui, "Aɢ", Color32::WHITE));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let g = shown.expect("drawn");
|
||||
assert_eq!(g.job.text, "AG");
|
||||
let [a, rest] = g.job.sections.as_slice() else { panic!("two sections: {:?}", g.job.sections) };
|
||||
assert!(rest.format.font_id.size < a.format.font_id.size, "the other letters are smaller capitals");
|
||||
ctx.fonts_mut(|f| {
|
||||
for sec in &g.job.sections {
|
||||
assert!(f.has_glyphs(&sec.format.font_id, &g.job.text), "UI font draws {:?}", g.job.text);
|
||||
}
|
||||
});
|
||||
assert_eq!(small_capital_to_capital('ʀ'), 'R');
|
||||
assert_eq!(small_capital_to_capital('x'), 'x');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_line_height_and_letter_spacing() {
|
||||
assert_eq!(parse_line_height("Auto", None), Some(json!("auto")));
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct Editor {
|
||||
collection: Option<String>,
|
||||
query: String,
|
||||
rename: Option<Rename>,
|
||||
/// The rename just started: its field takes the focus in the frame it's first drawn.
|
||||
rename_start: bool,
|
||||
/// The variable whose details (name, description, scopes) are open.
|
||||
details: Option<String>,
|
||||
/// The group the table shows ("space" or "color/brand"); None = every variable.
|
||||
@@ -240,15 +242,19 @@ fn rename_id(target: &Rename) -> Id {
|
||||
Id::new(("var_rename", target))
|
||||
}
|
||||
|
||||
fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) {
|
||||
ui.memory_mut(|m| m.request_focus(rename_id(&target).with("te")));
|
||||
/// Start renaming `target`. The field isn't drawn yet (a new collection, mode or variable shows next
|
||||
/// frame), and focusing it now would put a node that isn't there in the screen reader tree, which
|
||||
/// AccessKit aborts on (#224): the field takes the focus when drawn.
|
||||
fn start_rename(st: &mut Editor, target: Rename) {
|
||||
st.rename = Some(target);
|
||||
st.rename_start = true;
|
||||
}
|
||||
|
||||
/// The inline editor of a rename in progress: the new name once committed. The rename ends when
|
||||
/// the editor closes.
|
||||
fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option<String> {
|
||||
let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, false);
|
||||
let start = std::mem::take(&mut st.rename_start);
|
||||
let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, start);
|
||||
if !open {
|
||||
st.rename = None;
|
||||
}
|
||||
@@ -271,7 +277,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st:
|
||||
Icon::Plus,
|
||||
tr("Create collection"),
|
||||
) {
|
||||
create_collection(app, ui, local, st);
|
||||
create_collection(app, local, st);
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| {
|
||||
for view in views {
|
||||
@@ -332,11 +338,11 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st
|
||||
}
|
||||
if view.editable() {
|
||||
if resp.double_clicked() {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, tr("Rename"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, tr("Delete collection"), "") {
|
||||
@@ -421,11 +427,11 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
||||
continue;
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, tr("Rename group"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, tr("Delete group"), "") {
|
||||
@@ -468,14 +474,14 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st:
|
||||
}
|
||||
}
|
||||
|
||||
fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) {
|
||||
fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) {
|
||||
let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n));
|
||||
if let Some(id) =
|
||||
app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string))
|
||||
{
|
||||
st.library = None;
|
||||
st.collection = Some(id.clone());
|
||||
start_rename(ui, st, Rename::Collection(id));
|
||||
start_rename(st, Rename::Collection(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +509,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
if widgets::button(ui, tr("Create collection"), true, true).clicked() {
|
||||
create_collection(app, ui, view.vars, st);
|
||||
create_collection(app, view.vars, st);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -599,18 +605,18 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
||||
}
|
||||
ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary);
|
||||
if resp.double_clicked() {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, tr("Rename mode"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, tr("Duplicate mode"), "") {
|
||||
let name = unique(&format!("{} copy", m.name), |n| coll.modes.iter().any(|x| x.name == n));
|
||||
let made = app.run("variable.addMode", json!({"collection": coll.id, "name": name, "from": m.id}));
|
||||
if let Some(id) = made.and_then(|r| r["id"].as_str().map(str::to_string)) {
|
||||
start_rename(ui, st, Rename::Mode(coll.id.clone(), id));
|
||||
start_rename(st, Rename::Mode(coll.id.clone(), id));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
@@ -645,7 +651,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
||||
let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n));
|
||||
if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string))
|
||||
{
|
||||
start_rename(ui, st, Rename::Mode(coll.id.clone(), id));
|
||||
start_rename(st, Rename::Mode(coll.id.clone(), id));
|
||||
}
|
||||
}
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
@@ -679,7 +685,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
|
||||
}
|
||||
if editable {
|
||||
if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, tr("Edit variable"), "") {
|
||||
@@ -687,7 +693,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
|
||||
ui.close();
|
||||
}
|
||||
if !inherited && menu_item(ui, tr("Rename"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
start_rename(st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if !v.overrides.is_empty() && menu_item(ui, tr("Reset overrides"), "") {
|
||||
@@ -894,7 +900,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
||||
let params = json!({"collection": coll.id, "name": name, "type": kind.id()});
|
||||
if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) {
|
||||
st.query.clear();
|
||||
start_rename(ui, st, Rename::Variable(id));
|
||||
start_rename(st, Rename::Variable(id));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Renames in the variables window, drawn headless with the screen reader tree on: creating a
|
||||
//! collection opens its name for editing, and so does a double click on its row. On every frame
|
||||
//! the focused node must be in the AccessKit tree; the platform adapter (UI Automation on Windows)
|
||||
//! aborts the app when it isn't (#224).
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
|
||||
/// The variables window open on a document without collections, with AccessKit on.
|
||||
fn variables_window() -> Ui {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.ui.variables = Some(Default::default());
|
||||
u.ctx.enable_accesskit();
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
/// One frame with `events`, checking the focused node is in the screen reader tree.
|
||||
fn checked_frame(u: &mut Ui, events: Vec<Event>, k: usize) {
|
||||
let out = u.frame_output(events);
|
||||
let tree = out.platform_output.accesskit_update.expect("an AccessKit update");
|
||||
assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus);
|
||||
}
|
||||
|
||||
/// `clicks` clicks at `p` (two make a double click) and the frames that follow, each checked.
|
||||
fn click_checking_focus(u: &mut Ui, p: Pos2, clicks: usize) {
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
}
|
||||
let presses = [true, false].into_iter().cycle().take(clicks * 2).map(Some);
|
||||
for (k, pressed) in presses.chain(std::iter::repeat_n(None, 20)).enumerate() {
|
||||
let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
checked_frame(u, events.into_iter().collect(), k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type `name` and press Enter (each frame checked).
|
||||
fn type_name(u: &mut Ui, name: &str) {
|
||||
checked_frame(u, vec![Event::Text(name.into())], 0);
|
||||
for pressed in [true, false] {
|
||||
checked_frame(u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 1);
|
||||
}
|
||||
for k in 0..5 {
|
||||
checked_frame(u, vec![], k + 2);
|
||||
}
|
||||
}
|
||||
|
||||
fn collection_names(u: &Ui) -> Vec<String> {
|
||||
u.app.session.doc().unwrap().variables.collections.iter().map(|c| c.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_a_collection_puts_its_name_in_edit_with_the_focus_in_the_screen_reader_tree() {
|
||||
let mut u = variables_window();
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click_checking_focus(&mut u, text_center(&shapes, "Create collection"), 1);
|
||||
assert_eq!(collection_names(&u), ["Collection"], "a click creates one collection");
|
||||
// The keyboard is in its name: typing renames it.
|
||||
type_name(&mut u, "Brand");
|
||||
assert_eq!(collection_names(&u), ["Brand"]);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_collection_renames_it_with_the_focus_in_the_screen_reader_tree() {
|
||||
let mut u = variables_window();
|
||||
u.app.session.execute("variable.createCollection", &serde_json::json!({"name": "Spacing"})).unwrap();
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click_checking_focus(&mut u, text_center(&shapes, "Spacing"), 2);
|
||||
// The row's text was replaced by the field, whose text is selected: typing replaces it.
|
||||
type_name(&mut u, "Space");
|
||||
assert_eq!(collection_names(&u), ["Space"]);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Copy and paste as the browser does them: Cmd+C arrives as a copy event and the copy reaches the
|
||||
//! system clipboard only through egui's output; Cmd+V arrives as the key and a paste event holding
|
||||
//! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that
|
||||
//! was there before as a text layer instead of the copied layers.
|
||||
|
||||
use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_doc::NodeKind;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, Services, egui_copy_png, egui_copy_text};
|
||||
|
||||
/// The browser shell's clipboard: writes go out as egui output commands.
|
||||
struct Browser(egui::Context);
|
||||
|
||||
impl Services for Browser {
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
egui_copy_text(&self.0, s);
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
egui_copy_png(&self.0, w, h, rgba);
|
||||
}
|
||||
}
|
||||
|
||||
struct Page {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
/// The system clipboard's text.
|
||||
clipboard: String,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
fn new() -> Self {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let app = FigApp::new(s, Box::new(Browser(ctx.clone())));
|
||||
let mut p = Self { ctx, app, time: 0.0, clipboard: "text copied in another app".into() };
|
||||
for _ in 0..3 {
|
||||
p.frame(vec![]);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// One frame; copies in its output land on the clipboard, as eframe writes them.
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<OutputCommand> {
|
||||
self.frame_then(events, |_| {})
|
||||
}
|
||||
|
||||
/// One frame that runs `then` after the app's own UI (a menu item clicked during the frame).
|
||||
fn frame_then(&mut self, events: Vec<Event>, then: impl FnOnce(&mut FigApp)) -> Vec<OutputCommand> {
|
||||
self.time += 1.0 / 60.0;
|
||||
let input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
let mut then = Some(then);
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
app.ui(ui);
|
||||
if let Some(f) = then.take() {
|
||||
f(app);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
for c in &out.platform_output.commands {
|
||||
if let OutputCommand::CopyText(t) = c {
|
||||
self.clipboard = t.clone();
|
||||
}
|
||||
}
|
||||
out.platform_output.commands
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool) -> Event {
|
||||
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::MAC_CMD | Modifiers::COMMAND }
|
||||
}
|
||||
|
||||
fn copy(&mut self) {
|
||||
self.frame(vec![Self::key(Key::C, true), Event::Copy]);
|
||||
self.frame(vec![Self::key(Key::C, false)]);
|
||||
}
|
||||
|
||||
fn paste(&mut self) {
|
||||
let text = self.clipboard.clone();
|
||||
self.frame(vec![Self::key(Key::V, true), Event::Paste(text)]);
|
||||
self.frame(vec![Self::key(Key::V, false)]);
|
||||
}
|
||||
|
||||
fn count(&self, kind: fn(&NodeKind) -> bool) -> usize {
|
||||
self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count()
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(k: &NodeKind) -> bool {
|
||||
matches!(k, NodeKind::Rectangle)
|
||||
}
|
||||
|
||||
fn text(k: &NodeKind) -> bool {
|
||||
matches!(k, NodeKind::Text(_))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_c_then_cmd_v_pastes_the_copied_layers() {
|
||||
let mut p = Page::new();
|
||||
p.copy();
|
||||
assert_ne!(p.clipboard, "text copied in another app", "the copy reached the system clipboard");
|
||||
p.paste();
|
||||
assert_eq!(p.count(rect), 2, "the rectangle was pasted");
|
||||
assert_eq!(p.count(text), 0, "no text layer from the clipboard's old text");
|
||||
p.paste();
|
||||
assert_eq!(p.count(rect), 3, "and again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() {
|
||||
let mut p = Page::new();
|
||||
p.copy();
|
||||
p.clipboard = "hello from another app".into();
|
||||
p.paste();
|
||||
assert_eq!(p.count(rect), 1);
|
||||
assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_as_png_puts_an_image_on_the_clipboard() {
|
||||
let mut p = Page::new();
|
||||
let out = p.frame_then(vec![], |app| crate::menus::invoke(app, "edit.copyAsPng"));
|
||||
let img = out.iter().find_map(|c| if let OutputCommand::CopyImage(i) = c { Some(i.clone()) } else { None });
|
||||
let img = img.expect("an image was copied");
|
||||
assert!(img.size[0] > 0 && img.size[1] > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mismatched_image_buffer_is_dropped() {
|
||||
let ctx = egui::Context::default();
|
||||
for (w, h, rgba) in [(4, 4, &[0u8; 10][..]), (u32::MAX, u32::MAX, &[][..])] {
|
||||
let mut out = ctx.run_ui(RawInput::default(), |ui| egui_copy_png(ui.ctx(), w, h, rgba));
|
||||
out.textures_delta.clear();
|
||||
assert!(out.platform_output.commands.is_empty(), "{w}x{h}");
|
||||
}
|
||||
}
|
||||
@@ -625,6 +625,21 @@ pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String {
|
||||
.unwrap_or_else(|| "…".into())
|
||||
}
|
||||
|
||||
/// [`elide_font`] cut at its start, keeping its end: a path keeps its file and the folders
|
||||
/// nearest it.
|
||||
pub fn elide_start_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String {
|
||||
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w;
|
||||
if fits(s) {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
(1..chars.len())
|
||||
.rev()
|
||||
.map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::<String>().trim_start()))
|
||||
.find(|c| fits(c))
|
||||
.unwrap_or_else(|| "…".into())
|
||||
}
|
||||
|
||||
/// A row of icon buttons; returns the index clicked.
|
||||
pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option<usize>, cell: f32) -> Option<usize> {
|
||||
let names: Vec<&str> = items.iter().map(|(_, tip)| *tip).collect();
|
||||
|
||||
@@ -726,8 +726,10 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
- **What is kept.** The published library a library file holds (manifest and snapshot with
|
||||
per-asset versions) travels as a small `.figcraft` archive holding only it. The server reads it
|
||||
in full with the limits a library file is read with, requires the file's library id once one is
|
||||
kept and a newer version, then writes it beside the file (`library.bin`, atomically) and records
|
||||
id, name and version in the file's description. Publishing from the app first fetches the
|
||||
kept and a newer version, then writes it beside the file in a file of its own
|
||||
(`library-<version>.bin`, atomically) and only then records id, name and version in the file's
|
||||
description, so a failed write or a crash in between leaves the previous version served whole;
|
||||
the previous version's file is deleted after. Publishing from the app first fetches the
|
||||
server's version and publishes the next one from it, so per-asset versions continue whoever
|
||||
published last; a race is refused and the next publish follows.
|
||||
- **Using it.** A file enables the library with the link in place of a library file's path
|
||||
|
||||
@@ -51,7 +51,9 @@ unknown id is an error.
|
||||
|
||||
Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic
|
||||
calls never open dialogs: dialog requests are dropped. Returns the command's result (creation
|
||||
commands return `{"id": N}`).
|
||||
commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint
|
||||
JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}`
|
||||
(as in `docs/mcp.md`).
|
||||
|
||||
```json
|
||||
{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -43,13 +43,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here |
|
||||
| FreeBSD | none | tarball | |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) |
|
||||
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged |
|
||||
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
|
||||
@@ -30,12 +30,13 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
channel stay English. Accessibility names come from the same strings.
|
||||
- **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español
|
||||
(Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a
|
||||
restart; System takes the first of the OS's preferred languages that has a catalog (Windows'
|
||||
language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag
|
||||
falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other
|
||||
Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while
|
||||
Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS
|
||||
language, never Simplified) or the browser's language on the web.
|
||||
restart; System takes the first of the OS's preferred languages that has a catalog (on Windows
|
||||
only the first preferred language counts, since that list also holds the languages added for a
|
||||
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
|
||||
tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the
|
||||
other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
|
||||
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
|
||||
next OS language, never Simplified) or the browser's language on the web.
|
||||
`pref.language {language}` sets it from the CLI, MCP and the control channel.
|
||||
- **Regional overlays:** a regional catalog whose language has a catalog lists only what differs
|
||||
(`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the
|
||||
@@ -107,6 +108,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog |
|
||||
| 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) |
|
||||
| 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h |
|
||||
|
||||
+13
-7
@@ -128,10 +128,10 @@ Or in a project's `.mcp.json`:
|
||||
resolved `gridCell`.
|
||||
- **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given,
|
||||
else on the selection. New layers become the selection.
|
||||
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP
|
||||
tools, `color` may also be a hex string and gradients can be written
|
||||
`{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the
|
||||
engine's paint JSON.
|
||||
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex
|
||||
string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`;
|
||||
the engine reads them as its paint JSON, so `run_command`, `figcraft-cli run` and the control
|
||||
channel take them too. Effects passed through the MCP tools may use hex `color` strings as well.
|
||||
- **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a
|
||||
menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...;
|
||||
`normal` is Normal). Groups and frames start as pass-through: their children blend with what is
|
||||
@@ -301,9 +301,9 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
given, are or are in; default the selection) out of the library the file publishes, or let them
|
||||
back in (`on: false`; without `on` it toggles). The sign of it is the name: hiding puts a `.`
|
||||
before it, showing takes the leading `.` and `_` away. Returns `{ids, hidden}`; one undo step.
|
||||
- New files and pages: `file.new` and `page.add` give a page the colour `#F5F5F5`. Only the app
|
||||
with its dark theme showing makes them `#1E1E1E`; headless sessions (the CLI, this server) have
|
||||
no theme. Text made with `text.create` or `node.create` is black whatever the page here; in the
|
||||
- New files and pages: `file.new` gives its page the colour `#F5F5F5`. Only the app with its dark
|
||||
theme showing makes it `#1E1E1E`; headless sessions (the CLI, this server) have no theme.
|
||||
`page.add` gives the new page the current page's colour, wherever it runs. Text made with `text.create` or `node.create` is black whatever the page here; in the
|
||||
app (the control channel, `--attach`) it is white on a dark page unless `props` name a `fill`,
|
||||
as the Text tool (`pointer` with `tool.text`) makes it everywhere.
|
||||
- `transform.rotationOrigin {on?}` (`Alt+R`): show or hide the selection's rotation origin handle
|
||||
@@ -1406,6 +1406,12 @@ turned off 0°.
|
||||
With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and
|
||||
`alt` drags show live distances to the neighbours; `inspect_ui` reports them under
|
||||
`tool.overlay` (`guides`, and `measure_box` for the box measured to).
|
||||
- **Duplicating at a spacing**: a copy Alt-dragged off a layer with `pointer` snaps to that layer
|
||||
too (its edges, centres and equal spacing). `edit.duplicate {ids?, dx?, dy?}` without `dx`/`dy`
|
||||
repeats the last duplicate's page offset while its copies are what it duplicates (and still the
|
||||
selection), measured when it runs: Alt-drag or duplicate and move a copy, then each
|
||||
`edit.duplicate` steps out the next one as far again. Copies placed by an auto layout set no
|
||||
step; explicit `dx`/`dy` always win.
|
||||
- **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by
|
||||
one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto
|
||||
layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user